diff --git a/.env.example b/.env.example index feab564af..ac1f33283 100644 --- a/.env.example +++ b/.env.example @@ -366,6 +366,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # that authenticate API tokens as Bearer tokens instead of passwords, such # as Fastmail (JMAP_SERVER_URL=https://api.fastmail.com). # LOGIN_SHOW_TOKEN_LOGIN=true +# The desktop app's "Sign in with your browser" through this login page. On by default. +# Turn it off on a terminal server: another user there could send a link asking for a sign-in. +# DESKTOP_BROWSER_SIGN_IN=false # # Show the "Remember me" box (default false). Without it every sign-in is # remembered, provided SESSION_SECRET is set. The box also decides for SSO diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c85a7c84f..ad5592ec4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,3 +67,26 @@ jobs: - name: Lint run: npm run lint + + # The build the release tarball and a manual install use, with its guard at + # the end. Docker and the Lite images build another way (webpack, export), so + # nothing else would notice this one breaking before a release does. + standalone-build: + name: Standalone build and guard + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index e3862393a..c1be605b6 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -1,8 +1,6 @@ "use client"; import { Suspense, useEffect, useRef, useState } from "react"; -import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag"; -import { DESKTOP_CLIENT_ID_KEY, DESKTOP_REDIRECT_URI_KEY } from "@/lib/desktop/browser-sign-in"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useAuthStore } from "@/stores/auth-store"; @@ -12,8 +10,8 @@ import { LITE_OAUTH_AVAILABLE, getLiteOAuthRedirectUri } from "@/lib/auth/lite-o import { AlertCircle } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useParams } from "next/navigation"; -import { buildMobileHandoffUrl, mobileHandoffTarget } from "@/lib/auth/mobile-handoff"; -import { confirmMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client"; +import { buildMobileHandoffUrl, desktopHandoffDeclineUrl, desktopHandoffPort, desktopHandoffTarget, handoffTarget } from "@/lib/auth/mobile-handoff"; +import { confirmMobileHandoffCodeUrl, discardDesktopSignIn } from "@/lib/auth/mobile-handoff-client"; import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent"; import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton"; import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels"; @@ -32,7 +30,21 @@ function OAuthCallbackInner() { url: string | null; resolveUrl?: () => Promise; username: string; + /** The desktop app's: what ends it, if the user says no or leaves. */ + desktop?: { code: string; target: string; state: string }; } | null>(null); + // Unanswered, and the tab goes: the tokens the server parked go too. A yes has left this + // to the app, and "Not now" has already said. + const desktopPending = pendingHandoff?.desktop; + const desktopAnswered = useRef(false); + useEffect(() => { + if (!desktopPending) return; + const leave = () => { + if (!desktopAnswered.current) discardDesktopSignIn(desktopPending.code); + }; + window.addEventListener("pagehide", leave); + return () => window.removeEventListener("pagehide", leave); + }, [desktopPending]); // 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. @@ -118,12 +130,9 @@ function OAuthCallbackInner() { const prefix = getPathPrefix(params.locale as string); // Lite on Stalwart registers one locale-free URI (lib/auth/lite-oauth.ts); // the entry document brought us here from it. - // The desktop app's browser sign-in redirected to its loopback listener - // (lib/desktop/browser-sign-in.ts), which this page's origin can't tell. - const desktopRedirectUri = IS_DESKTOP_BUILD ? sessionStorage.getItem(DESKTOP_REDIRECT_URI_KEY) : null; - const redirectUri = desktopRedirectUri ?? (LITE_OAUTH_AVAILABLE + const redirectUri = LITE_OAUTH_AVAILABLE ? getLiteOAuthRedirectUri() - : `${window.location.origin}${prefix}/${params.locale}/auth/callback`); + : `${window.location.origin}${prefix}/${params.locale}/auth/callback`; loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId) .then((success) => { @@ -133,10 +142,6 @@ function OAuthCallbackInner() { sessionStorage.removeItem("oauth_server_url"); sessionStorage.removeItem("oauth_server_id"); sessionStorage.removeItem("oauth_add_account_mode"); - if (IS_DESKTOP_BUILD) { - sessionStorage.removeItem(DESKTOP_REDIRECT_URI_KEY); - sessionStorage.removeItem(DESKTOP_CLIENT_ID_KEY); - } let redirectTo = `${prefix}/${params.locale}`; try { const saved = sessionStorage.getItem('redirect_after_login'); @@ -160,7 +165,7 @@ function OAuthCallbackInner() { let mobileRedirectUri = ""; let mobileState: string | null = null; try { - mobileRedirectUri = mobileHandoffTarget(sessionStorage.getItem("mobile_redirect_uri")); + mobileRedirectUri = handoffTarget(sessionStorage.getItem("mobile_redirect_uri")); mobileState = sessionStorage.getItem("mobile_state"); } catch { /* sessionStorage may be unavailable */ } @@ -193,14 +198,22 @@ function OAuthCallbackInner() { sessionStorage.removeItem("mobile_redirect_uri"); sessionStorage.removeItem("mobile_state"); } catch { /* ignore */ } + const desktopTarget = desktopHandoffTarget(mobileRedirectUri); setPendingHandoff({ url: null, - resolveUrl: () => confirmMobileHandoffCodeUrl({ - target: mobileRedirectUri, - code: handoffCode, - state: handoffState, - }), - username: typeof data.server_url === "string" ? data.server_url : "", + resolveUrl: () => { + desktopAnswered.current = true; + return confirmMobileHandoffCodeUrl({ + target: mobileRedirectUri, + code: handoffCode, + state: handoffState, + }); + }, + ...(desktopTarget ? { desktop: { code: handoffCode, target: desktopTarget, state: handoffState } } : {}), + // Who is being handed over, where the server could say. Its address otherwise. + username: desktopTarget && typeof data.account === "string" && data.account + ? data.account + : typeof data.server_url === "string" ? data.server_url : "", }); return; } @@ -275,12 +288,22 @@ function OAuthCallbackInner() { url={pendingHandoff.url} resolveUrl={pendingHandoff.resolveUrl ?? null} username={pendingHandoff.username} + desktopPort={pendingHandoff.desktop ? desktopHandoffPort(pendingHandoff.desktop.target) : undefined} onError={() => { setPendingHandoff(null); setError("token_exchange_failed"); }} onCancel={() => { + const desktop = pendingHandoff.desktop; setPendingHandoff(null); + if (desktop) { + // Ends the flow here and at the app. A plain login page from here would be a way + // to get a browser session nobody asked for. + desktopAnswered.current = true; + discardDesktopSignIn(desktop.code); + window.location.replace(desktopHandoffDeclineUrl(desktop.target, desktop.state)); + return; + } router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`)); }} /> diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index b672dd854..ebd3dc38a 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -31,6 +31,7 @@ import { OfflineHost } from "@/components/offline/offline-host"; import { DeviceWipeHost } from "@/components/offline/device-wipe-host"; import { NotificationActionsHost } from "@/components/providers/notification-actions-host"; import { DesktopOsHost } from "@/components/desktop/desktop-os-host"; +import { EditedFilesHost } from "@/components/desktop/edited-files-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; @@ -92,6 +93,7 @@ export default async function LocaleLayout({ + {!IS_LITE && } {!IS_LITE && } diff --git a/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx index de689d054..4a514e138 100644 --- a/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx +++ b/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx @@ -6,9 +6,11 @@ import { EMPTY_POLICY_LAYERS, resolvePolicies } from '@/lib/desktop/policy-schem // The device's OS policy on the two ways into SSO: nothing starts towards a refused server. +const params = vi.hoisted(() => ({ value: null as URLSearchParams | null })); + vi.mock('next/navigation', () => ({ useParams: () => ({ locale: 'en' }), - useSearchParams: () => new URLSearchParams(), + useSearchParams: () => params.value ?? new URLSearchParams(), useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), usePathname: () => '/login', })); @@ -30,6 +32,7 @@ vi.mock('@/hooks/use-config', () => ({ oauthEnabled: true, oauthOnly: true, oauthClientId: 'webmail', + desktopBrowserSignIn: true, ...configOverrides.value, }), })); @@ -56,6 +59,7 @@ let fetched: string[]; beforeEach(() => { configOverrides.value = {}; + params.value = null; fetched = []; sessionStorage.clear(); vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { @@ -110,3 +114,243 @@ describe('automatic SSO', () => { await waitFor(() => expect(startedSso()).toBe(true), { timeout: 10000 }); }, 30_000); }); + +describe('sign-in with the browser, in the app', () => { + const REDIRECT = 'http://127.0.0.1:51234/bulwark-desktop/callback'; + + function appBridge(options: { servers?: string[]; browserSignIn?: boolean; complete?: () => unknown } = {}) { + const forced: Record = {}; + if (options.servers) forced.AllowedJmapServers = options.servers; + if (options.browserSignIn !== undefined) forced.BrowserSignIn = options.browserSignIn; + const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: forced }); + const controls = { + begin: vi.fn(async () => ({ ok: true, flowId: 'f1', redirectUri: REDIRECT, state: 'main-state' })), + complete: vi.fn(async () => (options.complete ? options.complete() : { ok: false, error: 'cancelled' })), + cancel: vi.fn(), + }; + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + mode: 'instance', + auth: { claim: () => controls }, + policy: { get: () => snapshot, onChange: () => () => {} }, + } as unknown as BulwarkDesktopBridge; + return controls; + } + + const browserButton = () => screen.findByTestId('desktop-browser-sign-in', {}, { timeout: 10000 }); + + it('stays the other choice next to the form', async () => { + configOverrides.value = { oauthEnabled: false, oauthOnly: false }; + appBridge(); + render(); + expect((await browserButton()).textContent).toContain('button'); + expect(document.getElementById('username')).not.toBeNull(); + expect(document.getElementById('password')).not.toBeNull(); + }, 30_000); + + it('is the main way in on an OAuth-only instance, with no form', async () => { + appBridge(); + render(); + await browserButton(); + expect(document.getElementById('password')).toBeNull(); + expect(await ssoButton()).toBeInTheDocument(); + }, 30_000); + + it('is gone when a policy says so, and the form stays', async () => { + configOverrides.value = { oauthEnabled: false, oauthOnly: false }; + appBridge({ browserSignIn: false }); + render(); + await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 }); + expect(screen.queryByTestId('desktop-browser-sign-in')).toBeNull(); + }, 30_000); + + it('is gone when the instance\'s admin turned it off', async () => { + configOverrides.value = { oauthEnabled: false, oauthOnly: false, desktopBrowserSignIn: false }; + appBridge(); + render(); + await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 }); + expect(screen.queryByTestId('desktop-browser-sign-in')).toBeNull(); + }, 30_000); + + it('is not there in a browser', async () => { + configOverrides.value = { oauthEnabled: false, oauthOnly: false }; + render(); + await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 }); + expect(screen.queryByTestId('desktop-browser-sign-in')).toBeNull(); + }, 30_000); + + it('starts nothing towards a server off the list, and says why', async () => { + const controls = appBridge({ servers: ['https://other.example.org'] }); + render(); + fireEvent.click(await browserButton()); + expect(await screen.findByText('server_refused')).toBeInTheDocument(); + expect(controls.begin).not.toHaveBeenCalled(); + }, 30_000); + + it('a flow that ran out says so, and the button comes back', async () => { + const controls = appBridge({ complete: () => ({ ok: false, error: 'timeout' }) }); + render(); + fireEvent.click(await browserButton()); + expect(await screen.findByText('timed_out')).toBeInTheDocument(); + expect(controls.complete).toHaveBeenCalledOnce(); + expect(await browserButton()).not.toBeDisabled(); + }, 30_000); + + it('a forced server says no to a server of the user\'s own, in what it asks the instance', async () => { + const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { InstanceUrl: 'https://bulwark.example.org' } }); + const controls = appBridge({ complete: () => ({ ok: true, code: 'c'.repeat(64), state: 'main-state' }) }); + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + mode: 'instance', + auth: { claim: () => controls }, + policy: { get: () => snapshot, onChange: () => () => {} }, + } as unknown as BulwarkDesktopBridge; + let sent: Record | null = null; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/auth/oauth/metadata')) return new Response(JSON.stringify(METADATA), { status: 200 }); + if (url.includes('/api/auth/desktop-handoff/redeem')) { + sent = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ error: 'blocked_by_policy' }), { status: 403 }); + } + return new Response('{}', { status: 200 }); + })); + render(); + fireEvent.click(await browserButton()); + await waitFor(() => expect(sent).toMatchObject({ custom_servers_allowed: false })); + }, 30_000); + + it('asks the instance with the device\'s list, and a refusal is the policy\'s notice', async () => { + appBridge({ servers: ['https://mail.example.org'], complete: () => ({ ok: true, code: 'c'.repeat(64), state: 'main-state' }) }); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + fetched.push(url); + if (url.includes('/api/auth/oauth/metadata')) return new Response(JSON.stringify(METADATA), { status: 200 }); + if (url.includes('/api/auth/desktop-handoff/redeem')) { + // The same rule as the field for a server of one's own, on an instance with a server of its own. + expect(JSON.parse(String(init?.body))).toMatchObject({ allowed_servers: ['https://mail.example.org'], custom_servers_allowed: true }); + return new Response(JSON.stringify({ error: 'blocked_by_policy' }), { status: 403 }); + } + return new Response('{}', { status: 200 }); + })); + render(); + fireEvent.click(await browserButton()); + expect(await screen.findByText('server_refused')).toBeInTheDocument(); + expect(fetched.some((url) => url.includes('/api/auth/desktop-handoff/redeem'))).toBe(true); + }, 30_000); +}); + +describe('the page the app opens in the browser', () => { + const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + const search = (entries: Record) => new URLSearchParams(entries); + + function handoffPage(checkAnswer: () => Response = () => new Response('{"ok":true}', { status: 200 }), parkAnswer: () => Response = () => new Response(JSON.stringify({ code: 'a'.repeat(64) }), { status: 200 })) { + configOverrides.value = { oauthEnabled: false, oauthOnly: false, loginShowTotp: true }; + params.value = search({ mobile_redirect_uri: 'http://127.0.0.1:51234/bulwark-desktop/callback', mobile_state: 's', mobile_code_challenge: CHALLENGE }); + const parks: Record[] = []; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + fetched.push(url); + if (url.includes('/api/auth/desktop-handoff/check')) return checkAnswer(); + if (url.includes('/api/auth/desktop-handoff/park')) { + parks.push(JSON.parse(String(init?.body))); + return parkAnswer(); + } + return new Response('{}', { status: 200 }); + })); + return parks; + } + + async function submit(totp?: string) { + render(); + await screen.findByTestId('desktop-handoff-banner', {}, { timeout: 10000 }); + fireEvent.change(document.getElementById('username')!, { target: { value: 'a@example.org' } }); + fireEvent.change(document.getElementById('password')!, { target: { value: 'secret' } }); + if (totp) { + fireEvent.click(screen.getByText('totp_toggle')); + fireEvent.change(await screen.findByLabelText('totp_label'), { target: { value: totp } }); + } + fireEvent.submit(document.getElementById('password')!.closest('form')!); + } + + it('holds the password until the yes: checked first, parked only then, with no browser session', async () => { + const parks = handoffPage(); + await submit(); + expect(await screen.findByText('consent_title')).toBeInTheDocument(); + expect(parks).toEqual([]); + expect(fetched.filter((url) => url.includes('/desktop-handoff/check'))).toHaveLength(1); + fireEvent.click(screen.getByRole('button', { name: 'consent_confirm' })); + await waitFor(() => expect(parks).toHaveLength(1)); + expect(parks[0]).toMatchObject({ code_challenge: CHALLENGE, state: 's', username: 'a@example.org', password: 'secret' }); + // No session anywhere: nothing went to the routes a browser sign-in writes cookies from. + expect(fetched.some((url) => /api\/auth\/(session|verify|token)/.test(url))).toBe(false); + }, 30_000); + + it('"Not now" parks nothing', async () => { + const parks = handoffPage(); + await submit(); + fireEvent.click(await screen.findByRole('button', { name: 'consent_cancel' })); + await waitFor(() => expect(screen.queryByText('consent_title')).toBeNull()); + expect(parks).toEqual([]); + }, 30_000); + + it('a two-factor code is not spent before the yes, as it works once', async () => { + const parks = handoffPage(); + await submit('123456'); + expect(await screen.findByText('consent_title')).toBeInTheDocument(); + expect(parks).toEqual([]); + expect(fetched.some((url) => url.includes('/desktop-handoff/check'))).toBe(false); + fireEvent.click(screen.getByRole('button', { name: 'consent_confirm' })); + await waitFor(() => expect(parks).toHaveLength(1)); + expect(parks[0]).toMatchObject({ totp: '123456' }); + }, 30_000); + + it('a wrong password stays on the form, with no question', async () => { + const parks = handoffPage(() => new Response(JSON.stringify({ error: 'invalid_credentials' }), { status: 401 })); + await submit(); + expect(await screen.findByText('error.invalid_credentials')).toBeInTheDocument(); + expect(screen.queryByText('consent_title')).toBeNull(); + expect(parks).toEqual([]); + }, 30_000); + + it('too many attempts says so, and not that something broke', async () => { + handoffPage(() => new Response(JSON.stringify({ error: 'too_many_attempts' }), { status: 429 })); + await submit(); + expect(await screen.findByText('too_many')).toBeInTheDocument(); + expect(screen.queryByText('error.server_error')).toBeNull(); + }, 30_000); + + it('a failure at the yes goes back to the form and says why', async () => { + handoffPage(undefined, () => new Response(JSON.stringify({ error: 'invalid_credentials' }), { status: 401 })); + await submit('000000'); + fireEvent.click(await screen.findByRole('button', { name: 'consent_confirm' })); + await waitFor(() => expect(screen.queryByText('consent_title')).toBeNull()); + expect(await screen.findByText(/totp_invalid/)).toBeInTheDocument(); + }, 30_000); + + it('is the ordinary page when the instance\'s admin turned it off', async () => { + configOverrides.value = { oauthEnabled: false, oauthOnly: false, desktopBrowserSignIn: false }; + params.value = search({ mobile_redirect_uri: 'http://127.0.0.1:51234/bulwark-desktop/callback', mobile_state: 's', mobile_code_challenge: CHALLENGE }); + render(); + await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 }); + expect(screen.queryByTestId('desktop-handoff-banner')).toBeNull(); + }, 30_000); + + it('ignores a loopback target with no challenge, and is the ordinary page', async () => { + configOverrides.value = { oauthEnabled: false, oauthOnly: false }; + params.value = search({ mobile_redirect_uri: 'http://127.0.0.1:51234/bulwark-desktop/callback', mobile_state: 's' }); + render(); + await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 }); + expect(screen.queryByTestId('desktop-handoff-banner')).toBeNull(); + }, 30_000); + + it('ignores a target that is not this computer', async () => { + configOverrides.value = { oauthEnabled: false, oauthOnly: false }; + params.value = search({ mobile_redirect_uri: 'http://evil.example:51234/bulwark-desktop/callback', mobile_state: 's', mobile_code_challenge: CHALLENGE }); + render(); + await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 }); + expect(screen.queryByTestId('desktop-handoff-banner')).toBeNull(); + }, 30_000); +}); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index d04544e1e..9e1ecbd53 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -6,7 +6,7 @@ import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag"; import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge"; import { customServerAllowedByDesktopPolicy, jmapServerAllowedByDesktopPolicy, useDesktopPolicy } from "@/lib/desktop/policy"; import { accountRefusedByDesktopPolicy } from "@/lib/desktop/refused-accounts"; -import { browserSignInAvailable, runDesktopBrowserSignIn } from "@/lib/desktop/browser-sign-in"; +import { browserSignInAvailable, cancelBrowserSignIn, runDesktopBrowserSignIn, runInstanceBrowserSignIn, type DesktopBrowserSignInOutcome } from "@/lib/desktop/browser-sign-in"; import { useRouter } from "@/i18n/navigation"; import { useParams, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -43,8 +43,8 @@ import { } from "@/lib/auth/lite-oauth"; import { toAsciiDomain } from "@/lib/idn"; import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view"; -import { buildMobileHandoffUrl, mobileCodeChallenge, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff"; -import { requestMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client"; +import { buildMobileHandoffCodeUrl, buildMobileHandoffUrl, desktopHandoffDeclineUrl, desktopHandoffPort, desktopHandoffTarget, mobileCodeChallenge, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff"; +import { checkDesktopSignIn, parkDesktopSignIn, requestMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client"; import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent"; import { HelpLinks } from "@/components/layout/help-links"; import { LoginSkeleton } from "@/components/boot-skeleton/login-skeleton"; @@ -194,11 +194,14 @@ function LoginPageContent() { const router = useRouter(); const t = useTranslations("login"); const tDesktop = useTranslations("desktop_sign_in"); + const tBrowser = useTranslations("desktop_browser_sign_in"); const tDesktopPolicy = useTranslations("desktop_policy"); const desktopPolicy = useDesktopPolicy(); // Only the desktop build sets these; a browser never shows them. const [desktopNotice, setDesktopNotice] = useState(null); const [desktopWaiting, setDesktopWaiting] = useState(false); + // Leaving the page ends a sign-in it's waiting for: the verifier goes with it. + useEffect(() => () => cancelBrowserSignIn(), []); const tSessionLimits = useTranslations("session_limits"); const tOffline = useTranslations("offline"); const tOfflineWipe = useTranslations("offlineWipe"); @@ -230,9 +233,16 @@ function LoginPageContent() { // server to park the sign-in on and keeps handing over in the fragment. const rawMobileCodeChallenge = IS_LITE ? "" : searchParams.get("mobile_code_challenge") ?? ""; const mobileChallenge = mobileCodeChallenge(rawMobileCodeChallenge); - const mobileRedirectUri = rawMobileCodeChallenge && !mobileChallenge + // The desktop app's loopback listener is the other target, only ever with a code and a + // state, and the desktop build itself has no hand-off routes (lib/desktop/blocked-routes.ts). + const { desktopBrowserSignIn: desktopSignInEnabled } = useConfig(); + const desktopTarget = !IS_DESKTOP_BUILD && desktopSignInEnabled && mobileChallenge && rawMobileState + ? desktopHandoffTarget(searchParams.get("mobile_redirect_uri")) + : ""; + const isDesktopHandoff = Boolean(desktopTarget); + const mobileRedirectUri = desktopTarget || (rawMobileCodeChallenge && !mobileChallenge ? "" - : mobileHandoffTarget(searchParams.get("mobile_redirect_uri")); + : mobileHandoffTarget(searchParams.get("mobile_redirect_uri"))); const mobileState = mobileRedirectUri ? rawMobileState : ""; const isMobileHandoff = Boolean(mobileRedirectUri); const [pendingHandoff, setPendingHandoff] = useState<{ @@ -392,7 +402,8 @@ function LoginPageContent() { const canRememberSession = rememberMeEnabled && (!IS_LITE || liteTokenLoginSupported !== false); // Sign-ins are remembered without asking unless the admin offers the box // (LOGIN_SHOW_REMEMBER_ME). - const showRememberMe = canRememberSession && loginShowRememberMe; + // Not in the browser the desktop app opened: nothing stays in this browser to remember. + const showRememberMe = canRememberSession && loginShowRememberMe && !isDesktopHandoff; const persistSession = canRememberSession && (!loginShowRememberMe || rememberMe); // A provider sign-in follows the box whenever it is shown, the automatic // one included (it starts unticked), and is remembered otherwise. Lite @@ -679,7 +690,8 @@ function LoginPageContent() { locale: params.locale, server_id: selectedServer?.id, remember: persistProviderSession, - ...(freshLoginRequired() ? { fresh_login: true } : {}), + // In the browser the desktop app opened, its word counts: the flag is in the app. + ...(freshLoginRequired() || (isDesktopHandoff && searchParams.get("fresh_login") === "1") ? { fresh_login: true } : {}), ...(isMobileHandoff ? { mobile_redirect_uri: mobileRedirectUri, @@ -692,6 +704,8 @@ function LoginPageContent() { if (!res.ok) { setOauthLoading(false); + // The app is waiting on this tab, so it can't just stay quiet here. + if (isDesktopHandoff) setHandoffError("server_error"); return; } @@ -713,7 +727,7 @@ function LoginPageContent() { } catch { setOauthLoading(false); } - }, [params.locale, selectedServer?.id, serverUrl, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]); + }, [params.locale, selectedServer?.id, serverUrl, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, isDesktopHandoff, searchParams, mobileRedirectUri, mobileState, mobileChallenge]); useEffect(() => { // After signing out, show the login page rather than signing straight @@ -1041,38 +1055,91 @@ function LoginPageContent() { window.location.href = authUrl.toString(); }; - /** The desktop build: the server's own sign-in page in the system browser. */ - // Spelled out, not IS_DESKTOP_BUILD: only this form keeps the route out of other - // builds' bundles, which Lite's check on API strings relies on. - const handleDesktopBrowserSignIn = async () => { + /** What the user sees when a browser sign-in ends without one. Cancelling says nothing. */ + const showBrowserSignInOutcome = (outcome: DesktopBrowserSignInOutcome) => { + setDesktopWaiting(false); + if (outcome.kind === "policy") setDesktopPolicyNotice(tDesktopPolicy("server_refused")); + else if (outcome.kind === "denied") setDesktopNotice(tBrowser("declined")); + else if (outcome.kind === "declined") setDesktopNotice(tDesktop("server_declined")); + else if (outcome.kind === "invalid") setDesktopNotice(tDesktop("server_invalid")); + else if (outcome.kind === "timeout") setDesktopNotice(tBrowser("timed_out")); + else if (outcome.kind === "no_browser") setDesktopNotice(tBrowser("no_browser")); + else if (outcome.kind === "failed") setDesktopNotice(tBrowser("failed")); + }; + + /** The server's or the instance's own sign-in page in the system browser. */ + const handleBrowserSignIn = async () => { + setDesktopNotice(null); + setDesktopPolicyNotice(null); + // Spelled out, not IS_DESKTOP_BUILD: only this form keeps the route out of other + // builds' bundles, which Lite's check on API strings relies on. if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1') { const target = jmapEndpoint.trim(); if (!target) { setDesktopNotice(tDesktop("server_needed")); return; } - setDesktopNotice(null); + if (!jmapServerAllowedByDesktopPolicy(target, desktopPolicy)) { + setDesktopPolicyNotice(tDesktopPolicy("server_refused")); + return; + } setDesktopWaiting(true); - const prefix = getPathPrefix(params.locale as string); const outcome = await runDesktopBrowserSignIn({ serverUrl: target, - callbackPath: `${prefix}/${params.locale}/auth/callback`, slot: useAccountStore.getState().getNextCookieSlot(), remember: persistProviderSession, addAccount: isAddAccountMode, + fresh: freshLoginRequired(), + redeem: (server, code, verifier, redirectUri) => useAuthStore.getState().loginWithOAuth(server, code, verifier, redirectUri), + tryPassword: async ({ username, password, totp }) => { + if (await useAuthStore.getState().login(target, username, password, totp, persistSession)) { + if (!rememberMeEnabled || persistSession) saveUsername(username); + return { ok: true }; + } + const failed = useAuthStore.getState().error; + return { ok: false, error: failed === "invalid_credentials" || failed === "totp_required" ? failed : "other" }; + }, }); - if (outcome.kind === "callback") { - window.location.href = outcome.callbackUrl; + if (outcome.kind === "signed-in") { + setDesktopWaiting(false); + router.push('/'); return; } - setDesktopWaiting(false); forgetDesktopServer(target, "failed"); - if (outcome.kind === "unavailable") setDesktopNotice(tDesktop("browser_unavailable")); - else if (outcome.kind === "declined") setDesktopNotice(tDesktop("server_declined")); - else if (outcome.kind === "failed") setDesktopNotice(tDesktop("browser_failed")); + showBrowserSignInOutcome(outcome); + return; } + // A server the device's OS policy refuses, where it's known up front. With a list of + // servers the browser picks, and the redeem below is what checks. + if (!hasServerList && serverUrl && !jmapServerAllowedByDesktopPolicy(serverUrl, desktopPolicy)) { + setDesktopPolicyNotice(tDesktopPolicy("server_refused")); + return; + } + setDesktopWaiting(true); + const outcome = await runInstanceBrowserSignIn({ + base: `${window.location.origin}${getPathPrefix(params.locale as string)}`, + locale: params.locale as string, + fresh: freshLoginRequired(), + }); + if (outcome.kind !== "code") { + showBrowserSignInOutcome(outcome); + return; + } + const redeemed = await useAuthStore.getState().loginWithDesktopHandoff(outcome.code, outcome.verifier, { + remember: persistProviderSession, + rememberPassword: persistSession, + allowedServers: desktopPolicy.AllowedJmapServers.value, + // The same rule as the field for a server of one's own on this page. + customServersAllowed: customServerAllowedByDesktopPolicy(desktopPolicy) || !configuredServerUrl, + }); + setDesktopWaiting(false); + if (redeemed === "ok") router.push('/'); + else if (redeemed === "blocked") setDesktopPolicyNotice(tDesktopPolicy("server_refused")); + else setDesktopNotice(tBrowser("failed")); }; + const browserSignInShown = browserSignInAvailable() && desktopSignInEnabled; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -1080,6 +1147,54 @@ function LoginPageContent() { // when the admin hasn't configured a server list. const effectiveServerUrl = selectedServer?.url || (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl); + // The desktop app opened this page in the user's browser. Nothing leaves this page until + // the user says yes: the password is only checked, and a two-factor code can't be, as it + // works once. This browser gets no session of its own out of any of it. + if (isDesktopHandoff) { + const failed = (reason: string) => { + useAuthStore.setState({ + isLoading: false, + error: reason === "invalid_credentials" || reason === "totp_required" ? reason : reason === "too_many_attempts" ? null : "server_error", + }); + setDesktopNotice(reason === "too_many_attempts" ? tBrowser("too_many") : null); + }; + const { username, password } = formData; + const totp = totpCode || undefined; + useAuthStore.setState({ isLoading: true, error: null }); + setHandoffError(null); + setDesktopNotice(null); + if (!totp) { + const checked = await checkDesktopSignIn({ serverUrl: effectiveServerUrl, serverId: selectedServer?.id, username, password }); + if ("error" in checked) { + failed(checked.error); + return; + } + } + useAuthStore.setState({ isLoading: false }); + const prefix = getPathPrefix(params.locale as string); + setPendingHandoff({ + url: null, + resolveUrl: async () => { + const parked = await parkDesktopSignIn({ + challenge: mobileChallenge, + state: mobileState, + serverUrl: effectiveServerUrl, + serverId: selectedServer?.id, + username, + password, + totp, + redirectUri: `${window.location.origin}${prefix}/${params.locale}/auth/callback`, + }); + if ("error" in parked) { + failed(parked.error); + return null; + } + return buildMobileHandoffCodeUrl(mobileRedirectUri, parked.code, mobileState); + }, + username, + }); + return; + } // The device's OS policy, also on an instance: nothing is sent to a server it refuses. setDesktopPolicyNotice(null); if (!jmapServerAllowedByDesktopPolicy(effectiveServerUrl, desktopPolicy)) { @@ -1193,6 +1308,53 @@ function LoginPageContent() { // has to pick which one SSO targets before the button starts the flow, since // the selection drives OAuth discovery and the server_id sent to the callback // (issue #799). + // In the app: the main way in, above the form, which stays as the other choice. + const browserSignInOption = browserSignInShown && ( +
+ + {desktopWaiting && ( +

+ {tBrowser("waiting_hint")}{" "} + +

+ )} +
+
+ +
+
+ {tBrowser("or_here")} +
+
+
+ ); + + // The desktop app's sign-in, in the browser: says what this page is for. + const desktopHandoffBanner = isDesktopHandoff && ( +

+ {tBrowser("handoff_banner")} +

+ ); + const desktopPolicyAlert = desktopPolicyNotice && (

{desktopPolicyNotice} @@ -1426,13 +1588,17 @@ function LoginPageContent() { url={pendingHandoff?.url ?? null} resolveUrl={pendingHandoff?.resolveUrl ?? null} username={pendingHandoff?.username ?? ""} + desktopPort={isDesktopHandoff ? desktopHandoffPort(desktopTarget) : undefined} onCancel={() => { setPendingHandoff(null); - router.push('/'); + // Ends the flow at the app, which isn't left waiting. Nothing was parked. + if (isDesktopHandoff) window.location.replace(desktopHandoffDeclineUrl(desktopTarget, mobileState)); + else router.push('/'); }} onError={() => { setPendingHandoff(null); - setHandoffError("server_error"); + // The desktop's answer has said what went wrong, on the form. + if (!isDesktopHandoff) setHandoffError("server_error"); }} /> {/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */} @@ -1598,6 +1764,8 @@ function LoginPageContent() { )} + {desktopHandoffBanner} + {/* Dev Mode: One-click login */} {devMode ? (

@@ -1629,10 +1797,14 @@ function LoginPageContent() { {serverPicker} {desktopPolicyAlert} {oauthMetadata && showRememberMe && rememberMeBox} + {browserSignInOption} {oauthMetadata ? (
) : ( /* Login Form */ @@ -1690,6 +1867,8 @@ function LoginPageContent() { )} + {browserSignInOption} + {/* Username field. The access token names the account, so token sign-in has none. */}
@@ -1897,7 +2076,10 @@ function LoginPageContent() { - )} {desktopPolicyAlert} - {IS_DESKTOP_BUILD && desktopNotice && ( + {desktopNotice && (

{desktopNotice}

diff --git a/app/(main)/admin/_pages/settings.tsx b/app/(main)/admin/_pages/settings.tsx index 97abe846d..b930c610a 100644 --- a/app/(main)/admin/_pages/settings.tsx +++ b/app/(main)/admin/_pages/settings.tsx @@ -133,6 +133,7 @@ export function SettingsPage() { + diff --git a/app/api/auth/desktop-handoff/check/route.ts b/app/api/auth/desktop-handoff/check/route.ts new file mode 100644 index 000000000..9500b9d2a --- /dev/null +++ b/app/api/auth/desktop-handoff/check/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { resolveDesktopHandoffServer } from '@/lib/auth/desktop-handoff-server'; +import { loginClientAddress } from '@/lib/auth/client-address'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; +import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget'; +import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; + +// The desktop app's sign-in, in the browser, before the question: is this password right? +// So a password manager never saves a wrong one, and the user isn't asked about a sign-in +// that can't work. Nothing is kept: the page holds the password until the user says yes. + +const NO_STORE = { 'Cache-Control': 'no-store' }; + +function answer(error: string, status: number) { + return NextResponse.json({ error }, { status, headers: NO_STORE }); +} + +export async function POST(request: NextRequest) { + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; + try { + const body = await request.json().catch(() => null); + const { server_url: serverUrl, username, password } = body ?? {}; + if ( + typeof serverUrl !== 'string' || !serverUrl || serverUrl.length > 2048 + || typeof username !== 'string' || !username || username.length > 512 + || typeof password !== 'string' || !password || password.length > 4096 + ) { + return answer('invalid_request', 400); + } + const server = await resolveDesktopHandoffServer(serverUrl, body?.server_id); + if ('refusal' in server) return answer(server.refusal.error, server.refusal.status); + + const clientIp = loginClientAddress(request, server.upstream); + const refund = await reserveLoginAttempt(clientIp); + if (!refund) { + return NextResponse.json( + { error: 'too_many_attempts' }, + { status: 429, headers: { ...NO_STORE, 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) } }, + ); + } + try { + await verifyJmapAuth(server.upstream, `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, { trusted: server.trusted, clientIp }); + await refund(); + return NextResponse.json({ ok: true }, { headers: NO_STORE }); + } catch (error) { + const status = error instanceof JmapAuthVerificationError ? error.upstreamStatus : undefined; + if (status === 401) return answer('invalid_credentials', 401); + await refund(); + // Stalwart: the right password on an account with a second factor. + if (status === 402) return answer('totp_required', 401); + return answer('login_failed', 502); + } + } catch (error) { + if (isStoreUnavailable(error)) return storeUnavailableResponse(NO_STORE); + logger.error('Desktop hand-off check error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return answer('Internal server error', 500); + } +} diff --git a/app/api/auth/desktop-handoff/discard/route.ts b/app/api/auth/desktop-handoff/discard/route.ts new file mode 100644 index 000000000..4be90ea52 --- /dev/null +++ b/app/api/auth/desktop-handoff/discard/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { revokeParkedTokens } from '@/lib/auth/desktop-handoff-revoke'; +import { discardDesktopHandoff, isHandoffCode } from '@/lib/auth/handoff-store'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; + +// "Not now" on an SSO sign-in the desktop app asked for, or its tab closed. sso/complete +// has to park the tokens before the question can be shown, so this is what ends them: gone +// from the store, and revoked where the provider can. Only the page holding the code knows it. + +const NO_STORE = { 'Cache-Control': 'no-store' }; + +export async function POST(request: NextRequest) { + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; + try { + const body = await request.json().catch(() => null); + if (!isHandoffCode(body?.code)) return NextResponse.json({ error: 'invalid_code' }, { status: 400, headers: NO_STORE }); + const parked = await discardDesktopHandoff(body.code); + if (parked) { + await revokeParkedTokens(parked); + logger.info('Desktop hand-off discarded', { flow: parked.flow }); + } + return NextResponse.json({ ok: true }, { headers: NO_STORE }); + } catch (error) { + if (isStoreUnavailable(error)) return storeUnavailableResponse(NO_STORE); + logger.error('Desktop hand-off discard error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE }); + } +} diff --git a/app/api/auth/desktop-handoff/park/route.ts b/app/api/auth/desktop-handoff/park/route.ts new file mode 100644 index 000000000..6e97c4fd3 --- /dev/null +++ b/app/api/auth/desktop-handoff/park/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { configManager } from '@/lib/admin/config-manager'; +import { loginClientAddress } from '@/lib/auth/client-address'; +import { resolveDesktopHandoffServer } from '@/lib/auth/desktop-handoff-server'; +import { isHandoffCodeChallenge, reserveDesktopHandoffPark, stashHandoff } from '@/lib/auth/handoff-store'; +import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; +import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget'; +import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; +import { DEFAULT_CLIENT_ID } from '@/lib/oauth/token-exchange'; +import { readFileEnv } from '@/lib/read-file-env'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; + +// The desktop app's "Sign in with your browser", on the browser's side, after the user +// said yes. /check tested the password; until now it has only been in the page. It goes +// no further than it has to: Stalwart's login turns it into tokens, and those are what's +// parked. A password is parked only for a server with no such login, for 60 seconds, for +// whoever holds the verifier. Nothing here writes a cookie, so this browser gets no session. +// +// A two-factor code works once, so it's only spent here, after the yes. Tokens minted here +// are never dropped for budget reasons: the budget is reserved first, and a success gives +// it back, so an office behind one address can sign in as often as it likes. + +const NO_STORE = { 'Cache-Control': 'no-store' }; + +function bounded(value: unknown, max: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= max; +} + +function answer(error: string, status: number, headers: Record = NO_STORE) { + return NextResponse.json({ error }, { status, headers }); +} + +const tooMany = () => answer('too_many_attempts', 429, { ...NO_STORE, 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) }); + +export async function POST(request: NextRequest) { + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; + try { + const body = await request.json().catch(() => null); + const challenge = body?.code_challenge; + const state = typeof body?.state === 'string' ? body.state : ''; + const totp = typeof body?.totp === 'string' && body.totp ? body.totp : undefined; + if ( + !isHandoffCodeChallenge(challenge) + || !state + || state.length > 512 + || !bounded(body?.server_url, 2048) + || !bounded(body?.username, 512) + || !bounded(body?.password, 4096) + || (totp !== undefined && totp.length > 16) + ) { + return answer('invalid_request', 400); + } + + const server = await resolveDesktopHandoffServer(body.server_url, body.server_id); + if ('refusal' in server) return answer(server.refusal.error, server.refusal.status); + const { entry, resolved, upstream, trusted, custom } = server; + + const clientIp = loginClientAddress(request, upstream); + // Before anything goes upstream, so a code is never spent on a sign-in that can't finish. + const refundPark = await reserveDesktopHandoffPark(clientIp); + if (!refundPark) return tooMany(); + const refundLogin = await reserveLoginAttempt(clientIp); + if (!refundLogin) return tooMany(); + + const configuredClientId = + entry?.oauth?.clientId || configManager.get('oauthClientId', '') || process.env.OAUTH_CLIENT_ID; + const clientSecret = + entry?.oauth?.clientSecret + || configManager.get('oauthClientSecret', '') + || process.env.OAUTH_CLIENT_SECRET + || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) + || ''; + const redirectUri = typeof body?.redirect_uri === 'string' && /^https?:\/\//.test(body.redirect_uri) ? body.redirect_uri : upstream; + const login = await stalwartPasswordLogin({ + serverUrl: upstream, + username: body.username, + password: body.password, + totp, + clientId: configuredClientId || DEFAULT_CLIENT_ID, + clientSecret, + redirectUri, + trusted, + clientIp, + registerUnknownClient: !configuredClientId, + }); + + let parked: Parameters[0]; + if (login.ok) { + parked = { + flow: 'desktop-tokens', + server_url: resolved, + server_id: entry?.id ?? null, + custom, + password_login: true, + username: body.username, + access_token: login.tokens.access_token, + refresh_token: login.tokens.refresh_token, + expires_in: login.tokens.expires_in ?? 3600, + signed_in_at: Date.now(), + }; + await refundLogin(); + } else if (login.error === 'invalid_credentials') { + return answer('invalid_credentials', 401); + } else if (login.error === 'totp_required') { + await refundLogin(); + return answer('totp_required', 401); + } else if (totp) { + await refundLogin(); + return answer('login_failed', 502); + } else { + // The last resort: a server with no token login, or one that won't give this client any. + try { + await verifyJmapAuth(upstream, `Basic ${Buffer.from(`${body.username}:${body.password}`).toString('base64')}`, { trusted, clientIp }); + await refundLogin(); + } catch (error) { + const status = error instanceof JmapAuthVerificationError ? error.upstreamStatus : undefined; + if (status === 401) return answer('invalid_credentials', 401); + await refundLogin(); + return status === 402 ? answer('totp_required', 401) : answer('login_failed', 502); + } + parked = { flow: 'desktop-password', server_url: resolved, server_id: entry?.id ?? null, custom, username: body.username, password: body.password }; + } + + const code = await stashHandoff(parked, { challenge, state }); + await refundPark(); + logger.info('Desktop hand-off parked', { flow: parked.flow }); + return NextResponse.json({ code }, { headers: NO_STORE }); + } catch (error) { + if (isStoreUnavailable(error)) return storeUnavailableResponse(NO_STORE); + logger.error('Desktop hand-off park error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return answer('Internal server error', 500); + } +} diff --git a/app/api/auth/desktop-handoff/redeem/route.ts b/app/api/auth/desktop-handoff/redeem/route.ts new file mode 100644 index 000000000..5db3b4a27 --- /dev/null +++ b/app/api/auth/desktop-handoff/redeem/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { logger } from '@/lib/logger'; +import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; +import { loginClientAddress } from '@/lib/auth/client-address'; +import { desktopHandoffEnabled } from '@/lib/auth/desktop-handoff-server'; +import { revokeParkedTokens } from '@/lib/auth/desktop-handoff-revoke'; +import { configManager } from '@/lib/admin/config-manager'; +import { isHandoffCode, isHandoffCodeVerifier, redeemHandoff, reserveHandoffRedeem } from '@/lib/auth/handoff-store'; +import { writeSignInStamp } from '@/lib/auth/session-age'; +import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; +import type { BudgetRefund } from '@/lib/auth/verify-budget'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { jmapServerAllowed } from '@/lib/desktop/policy-schema'; +import { getCookieOptions, getSignInCookieOptions } from '@/lib/oauth/cookie-config'; +import { storeIdToken } from '@/lib/oauth/end-session'; +import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; + +// The desktop app's side of "Sign in with your browser", on an instance. The app's own +// page posts the code its loopback listener received, with the PKCE verifier behind the +// challenge it opened the browser with. Same origin as the page, so an instance's cookies +// set here work under SameSite=Strict. Every attempt spends the code. +// +// The device's rules are the page's to send: only it knows the OS policy, and the browser +// the user signed in with knows nothing of it. That's its list of servers, and whether it +// may use a server of the user's own. A refused server gets nothing written, and a token +// minted for it is revoked. + +const NO_STORE = { 'Cache-Control': 'no-store' }; + +function answer(error: string, status: number, extra: Record = {}) { + return NextResponse.json({ error, ...extra }, { status, headers: NO_STORE }); +} + +export async function POST(request: NextRequest) { + if (IS_DESKTOP_BUILD) return answer('Not found', 404); + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; + await configManager.ensureLoaded(); + if (!desktopHandoffEnabled()) return answer('disabled', 403); + let refund: BudgetRefund | null = null; + try { + refund = await reserveHandoffRedeem(loginClientAddress(request)); + if (!refund) return answer('too_many_requests', 429); + + const body = await request.json().catch(() => null); + const code = body?.code; + const verifier = body?.code_verifier; + if (!isHandoffCode(code)) return answer('invalid_request', 400); + if (!isHandoffCodeVerifier(verifier)) { + await redeemHandoff(code, ''); + return answer('invalid_verifier', 400); + } + const slot = typeof body?.slot === 'number' && Number.isInteger(body.slot) && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS ? body.slot : 0; + const remember = body?.remember !== false; + const allowedServers = Array.isArray(body?.allowed_servers) ? (body.allowed_servers as string[]) : null; + + const result = await redeemHandoff(code, verifier); + if (!result.ok) { + logger.warn('Desktop hand-off redeem refused', { reason: result.reason }); + return answer(result.reason === 'verifier_mismatch' ? 'invalid_verifier' : `${result.reason}_code`, 400); + } + const parked = result.bundle; + // A code the mobile path parked isn't one of ours. + if (parked.flow !== 'desktop-password' && parked.flow !== 'desktop-tokens') return answer('invalid_code', 400); + if (!jmapServerAllowed(allowedServers, parked.server_url) || (parked.custom && body?.custom_servers_allowed !== true)) { + await revokeParkedTokens(parked); + return answer('blocked_by_policy', 403, { code: 'blocked_by_policy' }); + } + + await refund(); + logger.info('Desktop hand-off redeemed', { flow: parked.flow }); + + if (parked.flow === 'desktop-password') { + // Down the app's normal login path, from the page, like a typed password. + return NextResponse.json( + { flow: 'password', server_url: parked.server_url, username: parked.username, password: parked.password }, + { headers: NO_STORE }, + ); + } + + const cookieStore = await cookies(); + if (parked.refresh_token) { + const options = getSignInCookieOptions(remember); + cookieStore.set(refreshTokenCookieName(slot), parked.refresh_token, options); + writeSignInStamp(cookieStore, slot, { signedInAt: parked.signed_in_at, refreshToken: parked.refresh_token }, options); + } + const serverCookie = refreshTokenServerCookieName(slot); + if (parked.server_id) cookieStore.set(serverCookie, parked.server_id, getCookieOptions()); + else cookieStore.delete(serverCookie); + storeIdToken(cookieStore, slot, parked.id_token, request.nextUrl.basePath, [parked.refresh_token, parked.server_id], remember); + + return NextResponse.json( + { + flow: 'tokens', + server_url: parked.server_url, + ...(parked.password_login && parked.username ? { password_login: true, username: parked.username } : {}), + access_token: parked.access_token, + expires_in: parked.expires_in, + signed_in_at: parked.signed_in_at, + remember, + }, + { headers: NO_STORE }, + ); + } catch (error) { + if (isStoreUnavailable(error)) { + if (refund) await refund(); + return storeUnavailableResponse(NO_STORE); + } + logger.error('Desktop hand-off redeem error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return answer('Internal server error', 500); + } +} diff --git a/app/api/auth/desktop-oauth/route.ts b/app/api/auth/desktop-oauth/route.ts index c6fa99102..dd7ee0383 100644 --- a/app/api/auth/desktop-oauth/route.ts +++ b/app/api/auth/desktop-oauth/route.ts @@ -10,6 +10,8 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; /** The client id on a server that won't register one: 0.16 accepts any id. */ const FALLBACK_CLIENT_ID = 'bulwark-desktop'; const REGISTERED_REDIRECT = 'http://127.0.0.1/bulwark-desktop/callback'; +/** Only tried when the server says no to loopback. Neither Stalwart line takes this one. */ +const SCHEME_REDIRECT = 'bulwark://sign-in/callback'; const REGISTER_TIMEOUT_MS = 5000; function isLoopbackRedirect(value: unknown): boolean { @@ -27,29 +29,42 @@ function isLoopbackRedirect(value: unknown): boolean { * ids are stateless, so the same metadata gives the same id and nothing is * stored. It accepts a loopback redirect and ignores the port when matching. */ -async function registerClient(base: string, scope: string): Promise { +async function postRegistration(endpoint: string, redirectUri: string, scope: string): Promise { + return fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Bulwark', + redirect_uris: [redirectUri], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope, + }), + redirect: 'error', + signal: AbortSignal.timeout(REGISTER_TIMEOUT_MS), + }); +} + +async function clientIdOf(response: Response): Promise { + const body = (await response.json().catch(() => null)) as { client_id?: unknown } | null; + return typeof body?.client_id === 'string' && body.client_id ? body.client_id : null; +} + +async function registerClient(endpoint: string, scope: string): Promise<{ clientId: string; useScheme: boolean }> { try { - const response = await fetch(`${base}/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - client_name: 'Bulwark', - redirect_uris: [REGISTERED_REDIRECT], - token_endpoint_auth_method: 'none', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - scope, - }), - redirect: 'error', - signal: AbortSignal.timeout(REGISTER_TIMEOUT_MS), - }); - if (!response.ok) return FALLBACK_CLIENT_ID; - const body = (await response.json()) as { client_id?: unknown }; - return typeof body.client_id === 'string' && body.client_id ? body.client_id : FALLBACK_CLIENT_ID; + const response = await postRegistration(endpoint, REGISTERED_REDIRECT, scope); + if (response.ok) return { clientId: (await clientIdOf(response)) ?? FALLBACK_CLIENT_ID, useScheme: false }; + const refusal = (await response.json().catch(() => null)) as { error?: unknown } | null; + if (refusal?.error === 'invalid_redirect_uri') { + const second = await postRegistration(endpoint, SCHEME_REDIRECT, scope); + const clientId = second.ok ? await clientIdOf(second) : null; + if (clientId) return { clientId, useScheme: true }; + } } catch (error) { logger.warn('Desktop OAuth: client registration failed', { error: error instanceof Error ? error.message : String(error) }); - return FALLBACK_CLIENT_ID; } + return { clientId: FALLBACK_CLIENT_ID, useScheme: false }; } /** @@ -74,10 +89,17 @@ export async function POST(request: NextRequest) { if (!metadata) return NextResponse.json({ error: 'no_oauth' }, { status: 404 }); const scope = withProviderDataScopes(DEFAULT_OAUTH_SCOPES, metadata.scopes_supported); + // RFC 8414 says where. Without it, Stalwart's path: 0.16's documents don't always name it. + const validate = getDiscoveryValidator(upstream.serverUrl); + const named = metadata.registration_endpoint; + const endpoint = named && (!validate || (await validate(named))) ? named : `${upstream.serverUrl}/auth/register`; + const { clientId, useScheme } = await registerClient(endpoint, scope); return NextResponse.json({ authorization_endpoint: metadata.authorization_endpoint, - client_id: await registerClient(upstream.serverUrl, scope), + client_id: clientId, scope, + issuer: metadata.issuer, + ...(useScheme ? { use_scheme: true } : {}), }); } catch (error) { logger.error('Desktop OAuth discovery failed', { error: error instanceof Error ? error.message : 'Unknown error' }); diff --git a/app/api/auth/handoff/redeem/route.ts b/app/api/auth/handoff/redeem/route.ts index eb18c4967..d77935166 100644 --- a/app/api/auth/handoff/redeem/route.ts +++ b/app/api/auth/handoff/redeem/route.ts @@ -53,6 +53,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error }, { status: 400, headers: NO_STORE }); } + // The desktop app's sign-in has its own redeem, which writes cookies from these fields. + if (result.bundle.flow === 'desktop-password' || result.bundle.flow === 'desktop-tokens') { + return NextResponse.json({ error: 'invalid_code' }, { status: 400, headers: NO_STORE }); + } + await refund(); logger.info('Mobile hand-off redeemed', { flow: result.bundle.flow }); return NextResponse.json({ ...result.bundle, state: result.state }, { headers: NO_STORE }); diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts index dc2eb65a2..9e49c811f 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -10,6 +10,7 @@ import { } from '@/lib/oauth/token-exchange'; import { buildRedeemBundle } from '@/lib/auth/pair-bundle'; import { isHandoffCodeChallenge, stashHandoff } from '@/lib/auth/handoff-store'; +import { desktopHandoffTarget } from '@/lib/auth/mobile-handoff'; import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens'; import { getCookieOptions, getSignInCookieOptions } from '@/lib/oauth/cookie-config'; @@ -35,6 +36,32 @@ function webmailBaseFromCallback(redirectUri: string): string | null { } } +/** + * Who just signed in, for the question the desktop app's hand-off asks: the id token says, as + * it came straight from the provider, and otherwise the mail server's session does. + */ +async function accountName(serverUrl: string, accessToken: string, idToken: string | undefined): Promise { + try { + const claims = JSON.parse(Buffer.from((idToken ?? '').split('.')[1] ?? '', 'base64url').toString('utf8')) as Record; + for (const claim of ['email', 'preferred_username', 'name']) { + if (typeof claims[claim] === 'string' && claims[claim]) return claims[claim] as string; + } + } catch { + // Not a JWT, or none: the server can say. + } + try { + const session = await fetch(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'error', + signal: AbortSignal.timeout(5000), + }); + const body = (await session.json()) as { username?: unknown }; + return typeof body.username === 'string' ? body.username : ''; + } catch { + return ''; + } +} + export async function POST(request: NextRequest) { // CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax, // so a cross-site top-level POST would otherwise reach this handler. @@ -149,6 +176,30 @@ export async function POST(request: NextRequest) { // builds refuse a provider on another host, so those renew through // this webmail - the same bundle a paired phone gets. const { clientId, serverUrl } = getRequiredConfig(pendingServerId); + if (mobileRedirectUri && desktopHandoffTarget(mobileRedirectUri)) { + // The desktop app on this instance: its redeem writes its own cookies, the way + // the code below would have for this browser. sso/start made sure of a challenge. + if (!mobileCodeChallenge) throw new Error('desktop hand-off without a challenge'); + const handoffCode = await stashHandoff({ + flow: 'desktop-tokens', + server_url: serverUrl, + server_id: pendingServerId, + custom: false, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + id_token: tokens.id_token, + expires_in: tokens.expires_in ?? 3600, + signed_in_at: signedInAt, + }, { challenge: mobileCodeChallenge, state: mobileState ?? '', awaitConfirm: true }); + logger.info('Desktop hand-off parked', { flow: 'desktop-tokens' }); + return NextResponse.json({ + mobile_handoff_code: handoffCode, + server_url: serverUrl, + account: await accountName(serverUrl, tokens.access_token, tokens.id_token), + mobile_redirect_uri: mobileRedirectUri, + mobile_state: mobileState, + }); + } const bundle = buildRedeemBundle({ flow: 'oauth', serverUrl, diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index 6a8963af8..6fcb8bca9 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -12,7 +12,8 @@ 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 { isMobileCodeChallenge, mobileHandoffTarget } from '@/lib/auth/mobile-handoff'; +import { desktopHandoffTarget, isMobileCodeChallenge, mobileHandoffTarget } from '@/lib/auth/mobile-handoff'; +import { desktopHandoffEnabled } from '@/lib/auth/desktop-handoff-server'; const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_MAX_AGE = 300; // 5 minutes @@ -73,9 +74,18 @@ export async function POST(request: NextRequest) { : null; const mobileState = typeof rawMobileState === 'string' && rawMobileState ? rawMobileState : null; - if (mobileRedirectUri && !mobileHandoffTarget(mobileRedirectUri)) { + if (mobileRedirectUri && !mobileHandoffTarget(mobileRedirectUri) && !desktopHandoffTarget(mobileRedirectUri)) { return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 }); } + // The desktop app's listener only ever gets a code. Without a challenge there'd be + // tokens in the answer, on the way to a link. + if (mobileRedirectUri && desktopHandoffTarget(mobileRedirectUri) && !isMobileCodeChallenge(rawMobileCodeChallenge)) { + return NextResponse.json({ error: 'Invalid mobile_code_challenge' }, { status: 400 }); + } + // The admin turned the desktop app's sign-in off here (a terminal server, say). + if (mobileRedirectUri && desktopHandoffTarget(mobileRedirectUri) && !desktopHandoffEnabled()) { + return NextResponse.json({ error: 'disabled' }, { status: 403 }); + } // An app build that sends a PKCE challenge gets a one-time code instead // of the tokens (lib/auth/handoff-store.ts). A malformed one is refused // rather than ignored, which would hand the tokens over in the link. diff --git a/app/api/config/route.ts b/app/api/config/route.ts index de9521a46..6d14384fe 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -89,6 +89,7 @@ export async function GET(request: NextRequest) { loginShowSubtitle: configManager.get('loginShowSubtitle', true), loginShowTotp: configManager.get('loginShowTotp', true), loginShowTokenLogin: configManager.get('loginShowTokenLogin', false), + desktopBrowserSignIn: configManager.get('desktopBrowserSignIn', true), loginShowRememberMe: configManager.get('loginShowRememberMe', false), loginShowVersion: configManager.get('loginShowVersion', true), demoMode: configManager.get('demoMode', false), diff --git a/app/api/plugin-secrets/__tests__/wipe.test.ts b/app/api/plugin-secrets/__tests__/wipe.test.ts index 075138ac9..cc9bcaecc 100644 --- a/app/api/plugin-secrets/__tests__/wipe.test.ts +++ b/app/api/plugin-secrets/__tests__/wipe.test.ts @@ -16,8 +16,16 @@ vi.mock('@/lib/stalwart/credentials', () => ({ vi.mock('@/lib/admin/config-manager', () => ({ configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled: true } }) }, })); +vi.mock('@/lib/plugin-secrets/known-plugins', () => ({ + declaredUserCredential: async () => ({ inject: { type: 'bearer' } }), +})); +vi.mock('@/lib/plugin-secrets/proxy', () => ({ + adminCredentialOrigins: async () => [], + adminCredentialStatus: async () => [], +})); vi.mock('@/lib/plugin-secrets/vault', () => ({ VaultFullError: class extends Error {}, + userKey: (user: { username: string; serverUrl: string }) => `${Buffer.from(`${user.username}:${user.serverUrl}`).toString('hex')}0000000000000000`.slice(0, 64), deleteAllUserCredentials: vi.fn(async (user: unknown) => { deleted.push(user); }), deleteUserCredentials: vi.fn(async () => {}), listUserCredentials: vi.fn(async () => []), @@ -31,10 +39,23 @@ async function remove(query: string, headers: Record = { 'sec-fe } const EVICTED = 'all=true&username=alice&server=https%3A%2F%2Fmail.example.org'; +const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin' }; + +/** The proof cookie an account gets while signed in, as `name=value`. */ +async function proofFor(username: string): Promise { + vi.resetModules(); + const { setWipeProof } = await import('@/lib/plugin-secrets/wipe-proof'); + const { NextResponse } = await import('next/server'); + const answer = NextResponse.json({}); + setWipeProof(answer, { username, serverUrl: 'https://mail.example.org' }); + const cookie = answer.cookies.getAll()[0]; + return `${cookie.name}=${cookie.value}`; +} beforeEach(() => { deleted.length = 0; session.signedIn = false; + vi.stubEnv('SESSION_SECRET', 'wipe-test-session-secret-wipe-test-session-secret'); }); afterEach(() => { @@ -42,23 +63,58 @@ afterEach(() => { }); describe('DELETE /api/plugin-secrets?all=true', () => { - it('in the desktop app takes a dropped account by who it was, with no cookie', async () => { + it('in the desktop app takes a dropped account by who it was, with the proof it got while signed in', async () => { vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); - expect((await remove(EVICTED)).status).toBe(200); + const answer = await remove(EVICTED, { ...SAME_ORIGIN, cookie: await proofFor('alice') }); + expect(answer.status).toBe(200); expect(deleted).toEqual([{ username: 'alice', serverUrl: 'https://mail.example.org' }]); + expect(answer.headers.get('set-cookie')).toMatch(/Max-Age=0/i); + }); + + it('in the desktop app won\'t take any name on its own', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + expect((await remove(EVICTED)).status).toBe(401); + expect(deleted).toEqual([]); + }); + + it('in the desktop app won\'t take another account\'s proof, or the signed-in account\'s session, for that name', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + session.signedIn = true; + const bob = await proofFor('bob'); + const aliceName = (await proofFor('alice')).split('=')[0]; + expect((await remove(EVICTED, { ...SAME_ORIGIN, cookie: `${aliceName}=${bob.split('=').slice(1).join('=')}` })).status).toBe(401); + expect((await remove(EVICTED, { ...SAME_ORIGIN, cookie: `${aliceName}=forged` })).status).toBe(401); + expect(deleted).toEqual([]); }); it('won\'t take it from another site', async () => { vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); - expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site' })).status).toBe(403); + const cookie = await proofFor('alice'); + expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site', cookie })).status).toBe(403); expect(deleted).toEqual([]); }); + it('gives the proof when an account lists what it has, in the desktop app only', async () => { + session.signedIn = true; + const list = async () => { + vi.resetModules(); + const vault = await import('@/lib/plugin-secrets/vault'); + vi.mocked(vault.listUserCredentials).mockResolvedValueOnce([{ pluginId: 'p' } as never]); + const { GET } = await import('../route'); + return GET(new NextRequest('https://app.local/api/plugin-secrets', { headers: SAME_ORIGIN })); + }; + expect((await list()).headers.get('set-cookie')).toBeNull(); + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + expect((await list()).headers.get('set-cookie')).toMatch(/^jmap_psp_/); + }); + it('on an instance deletes nobody\'s, with a cookie or without', async () => { expect((await remove(EVICTED)).status).toBe(401); session.signedIn = true; await remove(EVICTED); expect(deleted).toEqual([]); + await remove(EVICTED, { ...SAME_ORIGIN, cookie: await proofFor('alice') }); + expect(deleted).toEqual([]); }); it('in the desktop app with a cookie and no name takes the signed-in account', async () => { diff --git a/app/api/plugin-secrets/route.ts b/app/api/plugin-secrets/route.ts index b767e0dbe..9ccce655b 100644 --- a/app/api/plugin-secrets/route.ts +++ b/app/api/plugin-secrets/route.ts @@ -15,6 +15,7 @@ import { } from '@/lib/plugin-secrets/vault'; import { storedDataUnavailableResponse } from '@/lib/stored-data-errors'; import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { clearWipeProof, hasWipeProof, setWipeProof } from '@/lib/plugin-secrets/wipe-proof'; const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; const MAX_SECRET_LENGTH = 4096; @@ -52,7 +53,9 @@ export async function GET(request: NextRequest) { const [admin, adminOrigins] = pluginId ? await Promise.all([adminCredentialStatus(pluginId), adminCredentialOrigins(pluginId)]) : [[], []]; - return NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE }); + const answer = NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE }); + if (IS_DESKTOP_BUILD && credentials.length > 0) setWipeProof(answer, user); + return answer; } /** @@ -107,21 +110,28 @@ export async function POST(request: NextRequest) { if (unavailable) return unavailable; throw error; } - return NextResponse.json({ ok: true }, { headers: NO_STORE }); + const answer = NextResponse.json({ ok: true }, { headers: NO_STORE }); + if (IS_DESKTOP_BUILD) setWipeProof(answer, user); + return answer; } /** DELETE /api/plugin-secrets?plugin=[&credential=] - disconnect. */ export async function DELETE(request: NextRequest) { const crossOrigin = rejectCrossOriginRequest(request); if (crossOrigin) return crossOrigin; - // An account dropped earlier, in the desktop app: no cookie left, and this server serves this device alone. + // An account dropped earlier, in the desktop app: no session cookie left, so it + // takes the proof that account got while it had one (lib/plugin-secrets/wipe-proof.ts). const { searchParams } = request.nextUrl; const owner = searchParams.get('username'); const server = searchParams.get('server'); if (IS_DESKTOP_BUILD && searchParams.get('all') === 'true' && owner && server) { + const named = { username: owner, serverUrl: server }; + if (!hasWipeProof(request, named)) return NextResponse.json({ error: 'Not authenticated' }, { status: 401, headers: NO_STORE }); try { - await deleteAllUserCredentials({ username: owner, serverUrl: server }); - return NextResponse.json({ ok: true }, { headers: NO_STORE }); + await deleteAllUserCredentials(named); + const answer = NextResponse.json({ ok: true }, { headers: NO_STORE }); + clearWipeProof(answer, named); + return answer; } catch (error) { const unavailable = storedDataUnavailableResponse(error, NO_STORE); if (unavailable) return unavailable; @@ -135,7 +145,9 @@ export async function DELETE(request: NextRequest) { if (IS_DESKTOP_BUILD && request.nextUrl.searchParams.get('all') === 'true') { try { await deleteAllUserCredentials(user); - return NextResponse.json({ ok: true }, { headers: NO_STORE }); + const answer = NextResponse.json({ ok: true }, { headers: NO_STORE }); + clearWipeProof(answer, user); + return answer; } catch (error) { const unavailable = storedDataUnavailableResponse(error, NO_STORE); if (unavailable) return unavailable; diff --git a/app/api/push/action/route.ts b/app/api/push/action/route.ts index 7ad419076..2e3d431d6 100644 --- a/app/api/push/action/route.ts +++ b/app/api/push/action/route.ts @@ -10,7 +10,8 @@ import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readBodyCapped, readJsonCapped } from '@/lib/security/capped-body'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { DisallowedUrlError } from '@/lib/security/url-guard'; -import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context'; +import type { StalwartAuthContext } from '@/lib/stalwart/auth-context'; +import { readPushAuthContext } from '@/lib/stalwart/push-auth-context'; import { rebaseApiUrl } from '@/lib/stalwart/jmap-api'; import type { SettingsPolicy } from '@/lib/admin/types'; import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch'; @@ -98,7 +99,7 @@ async function loginsNamed(login: string): Promise { const cookieStore = await cookies(); const found: StalwartAuthContext[] = []; for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { - const ctx = readStalwartAuthContextFromStore(cookieStore, slot); + const ctx = await readPushAuthContext(cookieStore, slot); if (ctx && (await loginFingerprint(ctx.serverUrl, ctx.username)) === login) found.push(ctx); } return found; diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index c862eeb99..111004c01 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { cleanPreview } from '@/lib/utils'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; -import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; +import { readPushAuthContext } from '@/lib/stalwart/push-auth-context'; import { getStalwartCredentials, type StalwartCredentials, @@ -45,15 +45,18 @@ interface ResolvedTarget { // session of everyone it is shared with, who sees only that folder - not its // Inbox, nor the message that just arrived there. // -// The push names nothing but the account id, and ids repeat across servers -// (and a group account is listed by each member). When that leaves more than -// one login, answer 'ambiguous' rather than guess: a wrong guess previews -// another login's mail and opens it there. -async function resolveTargetForAccount(accountId: string, forwarded: Record): Promise { +// Ids repeat across servers (and a group account is listed by each member), so +// the account id alone can leave several logins. On one server, the push's label +// names the login that registered it and settles it. Not across servers: logins +// with the same account id share one relay record in this browser (lib/web-push.ts +// keys it by account id), so the label is only whoever registered last. Whatever +// doesn't fit answers 'ambiguous' rather than guess: a wrong guess previews another +// login's mail and opens it there. +async function resolveTargetForAccount(accountId: string, login: string | null, forwarded: Record): Promise { const cookieStore = await cookies(); const probes: Promise<{ target: ResolvedTarget; own: boolean } | null>[] = []; for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { - const ctx = readStalwartAuthContextFromStore(cookieStore, slot); + const ctx = await readPushAuthContext(cookieStore, slot); if (!ctx) continue; const serverUrl = ctx.serverUrl.replace(/\/+$/, ''); probes.push( @@ -91,6 +94,13 @@ async function resolveTargetForAccount(accountId: string, forwarded: Record r !== null); + if (login && results.length > 0) { + const name = login.toLowerCase(); + const named = results.filter((r) => r.target.username.toLowerCase() === name || r.target.accountName?.toLowerCase() === name); + const servers = new Set(results.map((r) => r.target.serverUrl)); + if (named.length !== 1 || servers.size > 1) return 'ambiguous'; + return named[0].target; + } const owners = results.filter((r) => r.own); if (owners.length === 1) return owners[0].target; if (results.length === 1) return results[0].target; @@ -141,7 +151,7 @@ export async function GET(request: NextRequest) { let target: ResolvedTarget | null = null; let authHeader: string; if (requestedAccountId) { - const resolved = await resolveTargetForAccount(requestedAccountId, forwarded); + const resolved = await resolveTargetForAccount(requestedAccountId, request.nextUrl.searchParams.get('login'), forwarded); if (!resolved) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } diff --git a/components/auth/mobile-handoff-consent.tsx b/components/auth/mobile-handoff-consent.tsx index 695161a7e..0d2ea2cd2 100644 --- a/components/auth/mobile-handoff-consent.tsx +++ b/components/auth/mobile-handoff-consent.tsx @@ -19,6 +19,8 @@ interface MobileHandoffConsentProps { onCancel: () => void; /** `resolveUrl` returned null: nothing was handed over. */ onError?: () => void; + /** The desktop app, not the phone's: the port its listener asked from. */ + desktopPort?: string; } /** @@ -29,8 +31,10 @@ interface MobileHandoffConsentProps { * `bulwarkmobile` scheme on the device receives whatever is handed over * (lib/auth/mobile-handoff.ts). Nothing leaves without this confirmation. */ -export function MobileHandoffConsent({ url, resolveUrl, username, onCancel, onError }: MobileHandoffConsentProps) { +export function MobileHandoffConsent({ url, resolveUrl, username, onCancel, onError, desktopPort }: MobileHandoffConsentProps) { + const desktop = Boolean(desktopPort); const t = useTranslations("login"); + const tDesktop = useTranslations("desktop_browser_sign_in"); // The dialog closes itself right after confirming; while the code is being // made live, that close must not count as declining. const resolving = useRef(false); @@ -51,16 +55,18 @@ export function MobileHandoffConsent({ url, resolveUrl, username, onCancel, onEr return; } window.location.replace(resolved); - onCancel(); + // A custom scheme leaves this page where it is. The desktop app's listener is + // a page of its own, and going anywhere now could overtake it. + if (!desktop) onCancel(); }); return; } 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")} + title={desktop ? tDesktop("consent_title") : t("mobile_handoff.title")} + message={desktop ? tDesktop("consent_description", { username, port: desktopPort ?? "" }) : t("mobile_handoff.description", { username })} + confirmText={desktop ? tDesktop("consent_confirm") : t("mobile_handoff.confirm")} + cancelText={desktop ? tDesktop("consent_cancel") : t("mobile_handoff.cancel")} /> ); } diff --git a/components/desktop/__tests__/edited-files-host.test.tsx b/components/desktop/__tests__/edited-files-host.test.tsx new file mode 100644 index 000000000..30338054d --- /dev/null +++ b/components/desktop/__tests__/edited-files-host.test.tsx @@ -0,0 +1,334 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const policy = vi.hoisted(() => ({ decided: true, allowed: true })); +const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })); +const openMailLink = vi.hoisted(() => vi.fn()); +const handOffDraft = vi.hoisted(() => vi.fn()); +const openDraftWindow = vi.hoisted(() => vi.fn()); +const saveBlobAs = vi.hoisted(() => vi.fn(() => true)); +const signOutGates = vi.hoisted(() => [] as ((account: string) => Promise)[]); +const accounts = vi.hoisted(() => ({ list: [{ id: 'acct', cookieSlot: 2 }] as { id: string; cookieSlot: number; label?: string }[], active: 'acct' as string | null })); +const contentHidden = vi.hoisted(() => ({ value: false })); +const switchAccount = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); +vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock('@/stores/toast-store', () => ({ toast })); +vi.mock('@/lib/device-policy', () => ({ canDownload: () => policy.allowed, canExport: () => true })); +vi.mock('@/lib/offline/offline-mode', () => ({ scopeDecided: () => policy.decided })); +vi.mock('@/lib/desktop/mail-links', () => ({ openMailLink })); +vi.mock('@/lib/desktop/mail-notifications', () => ({ contentHiddenFor: () => contentHidden.value })); +vi.mock('@/lib/launch-handoff', () => ({ handOffDraft })); +vi.mock('@/lib/popout/open', () => ({ openDraftWindow })); +vi.mock('@/lib/download-guard', () => ({ saveBlobAs })); +vi.mock('@/lib/org/account-subjects', () => ({ useAccountSubjects: { subscribe: () => () => {} } })); +vi.mock('@/stores/policy-store', () => ({ usePolicyStore: { subscribe: () => () => {} } })); +vi.mock('@/lib/sign-out-cleanup', () => ({ + addSignOutGate: (gate: (account: string) => Promise) => { + signOutGates.push(gate); + return () => {}; + }, + onBeforeSignOut: () => () => {}, + onFullSignOut: () => {}, +})); +vi.mock('@/stores/account-store', () => { + const useAccountStore = (select: (s: { accounts: typeof accounts.list }) => unknown) => select({ accounts: accounts.list }); + useAccountStore.getState = () => ({ accounts: accounts.list }); + return { useAccountStore }; +}); +vi.mock('@/stores/auth-store', () => { + const useAuthStore = (select: (s: { activeAccountId: string | null }) => unknown) => select({ activeAccountId: accounts.active }); + useAuthStore.getState = () => ({ activeAccountId: accounts.active, switchAccount }); + return { useAuthStore }; +}); + +import { EditedFilesHost } from '../edited-files-host'; +import { resetEditSessions, takeFilesForReply, useEditSessions } from '@/lib/desktop/edit-attachments'; + +const session = (over: Record = {}) => ({ + id: 's1', name: 'a.docx', type: 'x/y', account: 'acct', source: '{"kind":"mail","emailId":"e1","ownerAccountId":"owner"}', changed: true, version: 1, hash: 'h', claimed: false, held: false, savedAs: false, lastChance: false, ...over, +}); + +let bridge: { edit: Record>; dialogs: { confirm: ReturnType }; os: { notify: ReturnType } }; + +function show(...sessions: ReturnType[]) { + bridge.edit.list.mockResolvedValue(sessions); + useEditSessions.setState({ sessions }); + return render(); +} + +beforeEach(() => { + policy.decided = true; + policy.allowed = true; + contentHidden.value = false; + accounts.list = [{ id: 'acct', cookieSlot: 2 }]; + accounts.active = 'acct'; + signOutGates.length = 0; + vi.clearAllMocks(); + saveBlobAs.mockReturnValue(true); + bridge = { + edit: { + canEdit: vi.fn(() => true), + begin: vi.fn(), + open: vi.fn(), + reopen: vi.fn(), + list: vi.fn(async () => []), + settle: vi.fn(async () => []), + read: vi.fn(async () => ({ ok: true, name: 'a.docx', type: 'x/y', data: new Uint8Array([1]), hash: 'h' })), + taken: vi.fn(async () => {}), + release: vi.fn(async () => {}), + discard: vi.fn(async () => ({ held: [] })), + forceDiscard: vi.fn(async () => ({ held: [] })), + askClear: vi.fn(async () => true), + clear: vi.fn(async () => ({ held: [] })), + wipe: vi.fn(async () => ({ held: [] })), + shown: vi.fn(), + setSource: vi.fn(), + claim: vi.fn(), + onChange: vi.fn(() => () => {}), + }, + dialogs: { confirm: vi.fn(async () => 0) }, + os: { notify: vi.fn(async () => ({ shown: true, playSound: false })) }, + }; + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, ...bridge }; + resetEditSessions(); +}); + +afterEach(() => { + cleanup(); + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('which offers show', () => { + it('a saved file nobody holds', () => { + show(session()); + expect(screen.getByTestId('edited-file')).toBeTruthy(); + }); + + it('nothing for an unchanged file or one a composer holds', () => { + show(session({ changed: false }), session({ id: 's2', claimed: true })); + expect(screen.queryByTestId('edited-file')).toBeNull(); + }); + + it('nothing for an account that is not signed in here', () => { + show(session({ account: 'someone-else' })); + expect(screen.queryByTestId('edited-file')).toBeNull(); + expect(bridge.os.notify).not.toHaveBeenCalled(); + }); + + it('nothing in a browser', () => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + useEditSessions.setState({ sessions: [session()] }); + render(); + expect(screen.queryByTestId('edited-file')).toBeNull(); + }); + + it('says it once per file on the OS, whatever the number of saves', async () => { + const { rerender } = show(session()); + await waitFor(() => expect(bridge.os.notify).toHaveBeenCalledTimes(1)); + act(() => useEditSessions.setState({ sessions: [session({ version: 2 })] })); + rerender(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(bridge.os.notify).toHaveBeenCalledTimes(1); + }); +}); + +describe('a message\'s attachment', () => { + it('attaches to a reply on that message, with that login', async () => { + show(session()); + fireEvent.click(screen.getByText('reply')); + await waitFor(() => expect(openMailLink).toHaveBeenCalled()); + const [link, options] = openMailLink.mock.calls[0]; + expect(link).toEqual({ kind: 'message', id: 'e1', slot: 2, ownerAccountId: 'owner', reply: true }); + expect(options).toEqual({ replyText: '' }); + const parked = takeFilesForReply('e1'); + expect(parked.map((f) => f.name)).toEqual(['a.docx']); + // Only the composer's having it makes it taken. + expect(bridge.edit.taken).not.toHaveBeenCalled(); + }); + + it('saves a copy through the guard, and only a saved copy counts as taken', async () => { + show(session()); + fireEvent.click(screen.getByText('save_copy')); + await waitFor(() => expect(saveBlobAs).toHaveBeenCalled()); + expect(saveBlobAs.mock.calls[0]).toEqual([expect.any(File), 'a.docx', { kind: 'attachment', subject: 'acct' }]); + expect(bridge.edit.taken).toHaveBeenCalledWith('s1', 'h'); + }); + + it('a save the guard refuses stays an offer', async () => { + saveBlobAs.mockReturnValue(false); + show(session()); + fireEvent.click(screen.getByText('save_copy')); + await waitFor(() => expect(saveBlobAs).toHaveBeenCalled()); + expect(bridge.edit.taken).not.toHaveBeenCalled(); + }); +}); + +describe('other sources', () => { + it('a closed draft opens in its window, where its chip offers the update', () => { + show(session({ source: '{"kind":"draft","draftId":"d1","ownerAccountId":"owner"}' })); + expect(screen.queryByText('reply')).toBeNull(); + fireEvent.click(screen.getByText('open_draft')); + expect(openDraftWindow).toHaveBeenCalledWith({ draftId: 'd1', localAccountId: 'acct', ownerAccountId: 'owner' }); + }); + + it('a file whose message went can start a new one', async () => { + show(session({ source: '{"kind":"file"}' })); + expect(screen.queryByText('reply')).toBeNull(); + expect(screen.queryByText('open_draft')).toBeNull(); + fireEvent.click(screen.getByText('new_message')); + await waitFor(() => expect(handOffDraft).toHaveBeenCalled()); + expect(handOffDraft.mock.calls[0][0].files[0].name).toBe('a.docx'); + }); +}); + +describe('discarding', () => { + it('goes to main, which asks natively about edits', async () => { + show(session()); + fireEvent.click(screen.getByRole('button', { name: 'discard a.docx' })); + await waitFor(() => expect(bridge.edit.discard).toHaveBeenCalledWith('s1')); + expect(bridge.dialogs.confirm).not.toHaveBeenCalled(); + }); +}); + +describe('the organisation', () => { + it('saying no ends a session with no question, says so on the OS, and stays until read', async () => { + policy.allowed = false; + show(session()); + await waitFor(() => expect(bridge.edit.forceDiscard).toHaveBeenCalledWith('s1')); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('removed_policy', { duration: 0 })); + expect(bridge.edit.discard).not.toHaveBeenCalled(); + await waitFor(() => expect(bridge.os.notify).toHaveBeenCalledWith(expect.objectContaining({ id: 'edit-policy-s1', title: 'removed_policy', body: 'outside_note' }))); + }); + + it('says when a program still has the file', async () => { + policy.allowed = false; + bridge.edit.forceDiscard.mockResolvedValue({ held: ['a.docx'] }); + show(session()); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('removed_policy_held', { duration: 0 })); + }); + + it('names no file on the OS where that account hides content', async () => { + policy.allowed = false; + contentHidden.value = true; + show(session()); + await waitFor(() => expect(bridge.os.notify).toHaveBeenCalledWith(expect.objectContaining({ title: 'removed_policy_hidden' }))); + }); + + it('does not end a session twice while a program holds it', async () => { + policy.allowed = false; + show(session({ held: true })); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(bridge.edit.forceDiscard).not.toHaveBeenCalled(); + }); + + it('not having said yet deletes nothing, and reads nothing either', async () => { + policy.decided = false; + show(session()); + fireEvent.click(screen.getByText('save_copy')); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('blocked_policy')); + expect(bridge.edit.forceDiscard).not.toHaveBeenCalled(); + expect(bridge.edit.read).not.toHaveBeenCalled(); + }); +}); + +describe('signing out', () => { + it('leaves the question to main, whose yes covers the deleting', async () => { + show(session()); + expect(signOutGates).toHaveLength(1); + bridge.edit.askClear.mockResolvedValueOnce(false); + await expect(signOutGates[0]('acct')).resolves.toBe(false); + expect(bridge.edit.askClear).toHaveBeenCalledWith('acct'); + }); +}); + +describe('what a program still holds', () => { + it('names the file, for an account signed in here, and what the app can\'t reach', () => { + show(session({ held: true, changed: false })); + expect(screen.getByTestId('edited-file-held').textContent).toContain('held_named'); + expect(screen.getByTestId('edited-file-held').textContent).toContain('outside_note'); + expect(screen.queryByTestId('edited-file')).toBeNull(); + }); + + it('names nothing for anyone else, and still says it', () => { + show(session({ held: true, changed: false, account: 'someone-else' })); + const notice = screen.getByTestId('edited-file-held').textContent ?? ''; + expect(notice).toContain('held_unnamed'); + expect(notice).not.toContain('held_named'); + }); + + it('a held file with edits nobody wanted is not offered', () => { + show(session({ held: true, changed: true })); + expect(screen.queryByTestId('edited-file')).toBeNull(); + }); +}); + +describe('the name on the OS', () => { + it('stays off where that account hides content', async () => { + contentHidden.value = true; + show(session()); + await waitFor(() => expect(bridge.os.notify).toHaveBeenCalledWith(expect.objectContaining({ title: 'changed_title_hidden' }))); + }); + + it('shows where it does not', async () => { + show(session()); + await waitFor(() => expect(bridge.os.notify).toHaveBeenCalledWith(expect.objectContaining({ title: 'changed_title' }))); + }); +}); + +describe('more than one account', () => { + it('says which one a file belongs to', () => { + accounts.list = [{ id: 'acct', cookieSlot: 2, label: 'work@example.org' }, { id: 'home', cookieSlot: 3, label: 'home@example.org' }]; + show(session()); + expect(screen.getByTestId('edited-file-account').textContent).toBe('work@example.org'); + }); + + it('with one, there is nothing to say', () => { + show(session()); + expect(screen.queryByTestId('edited-file-account')).toBeNull(); + }); + + it('a new message starts from the file\'s own account', async () => { + accounts.list = [{ id: 'acct', cookieSlot: 2 }, { id: 'home', cookieSlot: 3 }]; + accounts.active = 'home'; + const order: string[] = []; + switchAccount.mockImplementationOnce(async () => { order.push('switch'); }); + handOffDraft.mockImplementationOnce(() => { order.push('handoff'); }); + show(session({ source: '{"kind":"file"}' })); + fireEvent.click(screen.getByText('new_message')); + await waitFor(() => expect(handOffDraft).toHaveBeenCalled()); + expect(switchAccount).toHaveBeenCalledWith('acct'); + expect(order).toEqual(['switch', 'handoff']); + }); + + it('already on it, nothing switches', async () => { + show(session({ source: '{"kind":"file"}' })); + fireEvent.click(screen.getByText('new_message')); + await waitFor(() => expect(handOffDraft).toHaveBeenCalled()); + expect(switchAccount).not.toHaveBeenCalled(); + }); +}); + +describe('the last offer', () => { + it('says so, and tells main it was seen', async () => { + show(session({ lastChance: true })); + expect(screen.getByTestId('edited-file-last-chance')).toBeTruthy(); + await waitFor(() => expect(bridge.edit.shown).toHaveBeenCalledWith('s1')); + }); + + it('an ordinary offer says nothing of it', async () => { + show(session()); + expect(screen.queryByTestId('edited-file-last-chance')).toBeNull(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(bridge.edit.shown).not.toHaveBeenCalled(); + }); +}); + +describe('a file saved under a new name from a draft', () => { + it('has no draft to open: its own message is what it can go on', () => { + show(session({ source: '{"kind":"draft","draftId":"d1"}', savedAs: true })); + expect(screen.queryByText('open_draft')).toBeNull(); + expect(screen.getByText('new_message')).toBeTruthy(); + }); +}); diff --git a/components/desktop/edited-files-host.tsx b/components/desktop/edited-files-host.tsx new file mode 100644 index 000000000..5eaf7906f --- /dev/null +++ b/components/desktop/edited-files-host.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { FilePen, X } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "@/i18n/navigation"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { editForbidden, followEditSessions, getEditBridge, parkFilesForReply, parseEditSource, readEditedFile, useEditSessions } from "@/lib/desktop/edit-attachments"; +import { openMailLink } from "@/lib/desktop/mail-links"; +import { contentHiddenFor } from "@/lib/desktop/mail-notifications"; +import type { DesktopEditSession } from "@/lib/desktop/types"; +import { saveBlobAs } from "@/lib/download-guard"; +import { handOffDraft } from "@/lib/launch-handoff"; +import { useAccountSubjects } from "@/lib/org/account-subjects"; +import { openDraftWindow } from "@/lib/popout/open"; +import { addSignOutGate } from "@/lib/sign-out-cleanup"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { usePolicyStore } from "@/stores/policy-store"; +import { toast } from "@/stores/toast-store"; + +function useSignedInAccounts(): Set { + const accounts = useAccountStore((s) => s.accounts); + const active = useAuthStore((s) => s.activeAccountId); + return useMemo(() => new Set([...accounts.map((a) => a.id), ...(active ? [active] : [])]), [accounts, active]); +} + +/** + * Attachments opened with Edit and saved in their program, with what can be done with them. + * A composer offers its own files on the chip, so this has what no composer holds. It stays + * until the user has decided: a save may come at any time, also while the app was closed. + * Names only for accounts signed in here: someone else signing in doesn't see these, and + * hears only that a copy is still held somewhere. Main window only, nothing in a browser. + */ +export function EditedFilesHost() { + const t = useTranslations("desktopEdit"); + const router = useRouter(); + const tRef = useRef(t); + tRef.current = t; + const sessions = useEditSessions((s) => s.sessions); + const accounts = useSignedInAccounts(); + const accountList = useAccountStore((s) => s.accounts); + const [busy, setBusy] = useState(null); + const available = !!getEditBridge(); + + useEffect(() => { + if (available) followEditSessions(); + }, [available]); + + const offers = useMemo(() => sessions.filter((s) => s.changed && !s.claimed && !s.held && accounts.has(s.account)), [sessions, accounts]); + const held = useMemo(() => sessions.filter((s) => s.held), [sessions]); + const heldNames = held.filter((s) => accounts.has(s.account)).map((s) => s.name); + const heldOthers = held.length - heldNames.length; + + // Whoever is in the other program sees this. Once per file, not at every save. A file's + // name can say as much as a subject, so the same rule as for new mail. + const toldRef = useRef(new Set()); + useEffect(() => { + const os = getDesktopBridge()?.os; + for (const session of offers) { + if (toldRef.current.has(session.id)) continue; + toldRef.current.add(session.id); + void os?.notify({ + id: `edit-${session.id}`, + title: contentHiddenFor(session.account) ? tRef.current("changed_title_hidden") : tRef.current("changed_title", { name: session.name }), + body: tRef.current("changed_body"), + context: JSON.stringify({ editedFile: true }), + }).catch(() => {}); + } + }, [offers]); + + // Seen once, so a copy past its 30 days can go a day later. + useEffect(() => { + for (const session of offers) if (session.lastChance) getEditBridge()?.shown(session.id); + }, [offers]); + + // The organisation's no ends a session, also one with a file open and edits in it. Someone + // may be in the other program, so the OS says it as well, and the toast stays until read. + useEffect(() => { + const bridge = getEditBridge(); + if (!bridge) return; + const enforce = () => { + for (const session of useEditSessions.getState().sessions) { + if (session.held || !editForbidden(session.account)) continue; + void bridge.forceDiscard(session.id).then((result) => { + if (result.refused) return; + const message = tRef.current(result.held.length > 0 ? "removed_policy_held" : "removed_policy", { name: session.name }); + toast.error(message, { duration: 0 }); + void getDesktopBridge()?.os?.notify({ + id: `edit-policy-${session.id}`, + title: contentHiddenFor(session.account) ? tRef.current("removed_policy_hidden") : message, + body: tRef.current("outside_note"), + context: JSON.stringify({ editedFile: true }), + }).catch(() => {}); + }); + } + }; + enforce(); + const stops = [usePolicyStore.subscribe(enforce), useAccountSubjects.subscribe(enforce), useEditSessions.subscribe(enforce)]; + return () => stops.forEach((stop) => stop()); + }, []); + + // Before a sign-out the user started. Main asks, as it's what would delete, and names what a + // program still has open. A wipe doesn't ask. + useEffect(() => { + const bridge = getEditBridge(); + if (!bridge) return; + return addSignOutGate((accountId) => bridge.askClear(accountId)); + }, []); + + if (!available || (offers.length === 0 && held.length === 0)) return null; + + const withFile = async (session: DesktopEditSession, then: (file: File, hash: string) => void | Promise) => { + setBusy(session.id); + try { + const outcome = await readEditedFile(session); + if (outcome.ok) await then(outcome.file, outcome.hash); + else if (outcome.reason === "policy") toast.error(t("blocked_policy")); + else if (outcome.reason === "busy") toast.error(t("read_busy", { name: session.name })); + else if (outcome.reason !== "locked") toast.error(t("read_failed", { name: session.name })); + } catch (error) { + console.error("Could not read an edited file:", error); + toast.error(t("read_failed", { name: session.name })); + } finally { + setBusy(null); + } + }; + + const attachToReply = (session: DesktopEditSession, emailId: string, ownerAccountId: string | undefined) => withFile(session, (file) => { + parkFilesForReply(emailId, [file]); + const slot = useAccountStore.getState().accounts.find((a) => a.id === session.account)?.cookieSlot; + openMailLink( + { kind: "message", id: emailId, ...(slot !== undefined ? { slot } : {}), ownerAccountId, reply: true }, + { replyText: "" }, + (path) => router.push(path), + ); + }); + + /** From the account the file belongs to, whichever one is on screen. */ + const attachToNew = (session: DesktopEditSession) => withFile(session, async (file) => { + const auth = useAuthStore.getState(); + if (auth.activeAccountId !== session.account) await auth.switchAccount(session.account); + handOffDraft({ files: [file] }); + }); + + const saveCopy = (session: DesktopEditSession) => withFile(session, (file, hash) => { + if (saveBlobAs(file, file.name, { kind: "attachment", subject: session.account })) void getEditBridge()?.taken(session.id, hash); + }); + + const accountLabel = (session: DesktopEditSession) => { + if (accountList.length < 2) return null; + const entry = accountList.find((a) => a.id === session.account); + return entry?.label || entry?.username || null; + }; + + return ( +
+ {held.length > 0 && ( +
+ {heldNames.length > 0 &&

{t("held_named", { names: heldNames.join(", ") })}

} + {heldOthers > 0 &&

{t("held_unnamed")}

} +

{t("outside_note")}

+
+ )} + {offers.map((session) => { + const source = parseEditSource(session.source); + const draftId = source.kind === "draft" && !session.savedAs ? source.draftId : null; + const account = accountLabel(session); + return ( +
+
+
+
+ {source.kind === "mail" && ( + + )} + {draftId && ( + + )} + {source.kind !== "mail" && !draftId && ( + + )} + +
+
+ ); + })} +
+ ); +} diff --git a/components/email/__tests__/composer-edit-attachments.test.tsx b/components/email/__tests__/composer-edit-attachments.test.tsx new file mode 100644 index 000000000..a71161ac5 --- /dev/null +++ b/components/email/__tests__/composer-edit-attachments.test.tsx @@ -0,0 +1,316 @@ +// The composer's end of the desktop app's Edit, on the real composer. +import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { resetEditSessions, useEditSessions } from '@/lib/desktop/edit-attachments'; +import { useAuthStore } from '@/stores/auth-store'; + +// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ──────────────── + +const editorKeyDown = vi.hoisted(() => vi.fn()); +const toastError = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/offline/offline-mode', async (original) => ({ + ...(await original()), + scopeDecided: () => true, +})); + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { + 'data-testid': 'rich-text-editor', + onKeyDown: (event: React.KeyboardEvent) => { + if (!event.defaultPrevented) editorKeyDown(); + }, + }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ + useFocusTrap: () => ({ current: null }), +})); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +// ─── Store mocks ────────────────────────────────────────────────────────────── + +vi.mock('@/stores/auth-store', () => { + const state = { + client: null, + identities: [], + primaryIdentity: null, + isAuthenticated: false, + isDemoMode: false, + activeAccountId: null, + connectionLost: false, + getClientForAccount: () => undefined, + getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, + refreshIdentities: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useIdentityStore: hook }; +}); + +vi.mock('@/stores/account-store', () => { + const state = { accounts: [], getAccountById: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useAccountStore: hook }; +}); + +vi.mock('@/stores/email-store', () => { + const state = { + draftSaveEnabled: false, + sendRawEmail: async () => ({ sent: true }), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +const updateSetting = vi.fn(); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: (...args: unknown[]) => updateSetting(...args), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useContactStore: hook }; +}); + +vi.mock('@/stores/template-store', () => { + const state = { templates: [], addTemplate: async () => {} }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useTemplateStore: hook }; +}); + +// ─── Misc dependency mocks ──────────────────────────────────────────────────── + +vi.mock('@/stores/toast-store', () => ({ + toast: { info: () => {}, error: toastError, success: () => {} }, +})); + +vi.mock('@/lib/plugin-hooks', () => ({ + identityHooks: { + onSignatureRender: { size: 0, transform: async (value: unknown) => value }, + onIdentitySelect: { size: 0, emit: async () => {} }, + }, + templateHooks: { + onTemplateRender: { size: 0, transform: async (value: unknown) => value }, + onTemplateApply: { size: 0, emit: async () => {} }, + }, + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, + onComposeSend: { takeOver: async () => 'default' }, + onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, +})); + +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', +})); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ + buildQuotedHtmlBlock: () => '', + serializeEditorContent: () => '', +})); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +// Discard used to share the X button's path: without unsaved changes it just +// closed, so a draft reopened via "Cancel and edit" stayed in Drafts. + +const REOPENED_DRAFT = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Lunch', + body: '

Hello there

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: 'draft-1', +}; + +const session = (over: Record = {}) => ({ + id: 's1', name: 'a.txt', type: 'text/plain', account: 'acct', source: '{"kind":"draft","draftId":"draft-1"}', + changed: false, version: 0, hash: 'h', claimed: false, held: false, savedAs: false, lastChance: false, ...over, +}); + +function fakeEdit() { + return { + canEdit: () => true, + begin: vi.fn(), + open: vi.fn(), + reopen: vi.fn(), + list: vi.fn(async () => useEditSessions.getState().sessions), + settle: vi.fn(async () => useEditSessions.getState().sessions), + read: vi.fn(), + taken: vi.fn(async () => {}), + release: vi.fn(async () => {}), + discard: vi.fn(async () => ({ held: [] })), + forceDiscard: vi.fn(async () => ({ held: [] })), + askClear: vi.fn(async () => true), + clear: vi.fn(async () => ({ held: [] })), + wipe: vi.fn(async () => ({ held: [] })), + shown: vi.fn(), + setSource: vi.fn(), + claim: vi.fn(), + onChange: vi.fn(() => () => {}), + }; +} + +const DRAFT_WITH_FILE = { ...REOPENED_DRAFT, attachments: [{ blobId: 'b1', name: 'a.txt', type: 'text/plain', size: 3 }] }; + +describe('the composer with a file open for editing', () => { + let edit: ReturnType; + + beforeEach(() => { + edit = fakeEdit(); + window.bulwarkDesktop = { version: 1, platform: 'win32', edit, dialogs: { confirm: vi.fn(async () => 0) } } as never; + resetEditSessions(); + useAuthStore.setState({ activeAccountId: 'acct' } as never); + }); + + afterEach(() => { + window.bulwarkDesktop = undefined; + useAuthStore.setState({ activeAccountId: null } as never); + }); + + it('a double click on Send, while it looks at the file, sends once', async () => { + useEditSessions.setState({ sessions: [session({ changed: true })] }); + // Both clicks wait on the same answer, so both would go on. + let answer: (value: unknown) => void = () => {}; + const looking = new Promise((resolve) => { answer = resolve; }); + edit.settle.mockImplementation(() => looking as never); + const onSend = vi.fn(async () => {}); + render(); + await screen.findAllByTestId('composer-edit-update'); + + const send = screen.getAllByTestId('composer-send')[0]; + fireEvent.click(send); + fireEvent.click(send); + await act(async () => { answer([session({ changed: true })]); }); + await waitFor(() => expect(onSend).toHaveBeenCalled()); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); }); + expect(onSend).toHaveBeenCalledTimes(1); + // One question, too: each click would otherwise put up its own. + expect((window.bulwarkDesktop as unknown as { dialogs: { confirm: ReturnType } }).dialogs.confirm).toHaveBeenCalledTimes(1); + }); + + it('with nothing open for editing, Send looks at nothing', async () => { + const onSend = vi.fn(async () => {}); + render(); + fireEvent.click(screen.getAllByTestId('composer-send')[0]); + await waitFor(() => expect(onSend).toHaveBeenCalled()); + expect(edit.settle).not.toHaveBeenCalled(); + }); + + const forward = (phishingMarked: boolean) => ( + + ); + + it('a part forwarded from an ordinary message has Edit', async () => { + render(forward(false)); + expect(await screen.findByTestId('composer-edit')).toBeTruthy(); + }); + + it('a part forwarded from a message marked as phishing has none', async () => { + render(forward(true)); + await screen.findByText('a.txt'); + expect(screen.queryByTestId('composer-edit')).toBeNull(); + }); + + it('the mark stays on the part through a hand-over to another window', async () => { + render(); + await screen.findByText('a.txt'); + expect(screen.queryByTestId('composer-edit')).toBeNull(); + }); +}); diff --git a/components/email/__tests__/edit-attachment-gates.test.tsx b/components/email/__tests__/edit-attachment-gates.test.tsx new file mode 100644 index 000000000..de7c3b84d --- /dev/null +++ b/components/email/__tests__/edit-attachment-gates.test.tsx @@ -0,0 +1,141 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { PHISHING_KEYWORD } from '@/lib/phishing-report'; + +// Where a message's attachment gets Edit, on the real components with the real check. Only +// the organisation's answer is stood in for: whether it has spoken, which needs a server. + +vi.mock('@/lib/offline/offline-mode', async (original) => ({ + ...(await original()), + scopeDecided: () => true, +})); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false })); +vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false })); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); +const { ThreadConversationView } = await import('../thread-conversation-view'); +const { useAuthStore } = await import('@/stores/auth-store'); + +const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[]; + +const part = (over: Record = {}) => ({ partId: '2', blobId: 'blob-1', name: 'report.docx', type: 'application/octet-stream', size: 10, disposition: 'attachment', ...over }); + +function makeEmail(attachment: Record, keywords: Record = { $seen: true }): Email { + return { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: true, size: 100, + textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], + attachments: [attachment], bodyValues: { 1: { value: 'Hello' } }, + } as unknown as Email; +} + +/** Answers anything the views ask of a client with nothing. */ +const fakeClient = new Proxy({}, { get: (_target, name) => (name === 'then' ? undefined : () => undefined) }); + +let popup: ReturnType; + +beforeEach(() => { + popup = vi.fn(async () => null); + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + menu: { setState: () => {}, popup }, + // The shell's list, for the ones used here. + edit: { canEdit: (name: string) => /\.(docx|txt)$/.test(name), list: async () => [], onChange: () => () => {} }, + } as never; + useAuthStore.setState({ activeAccountId: 'acct', client: fakeClient } as never); +}); + +afterEach(() => { + cleanup(); + window.bulwarkDesktop = undefined; + useAuthStore.setState({ activeAccountId: null, client: null } as never); +}); + +async function viewerMenu(email: Email): Promise<(string | undefined)[]> { + render(); + const tile = await screen.findByTestId('attachment'); + fireEvent.contextMenu(tile.parentElement ?? tile, { clientX: 5, clientY: 5 }); + await waitFor(() => expect(popup).toHaveBeenCalled()); + return (popup.mock.calls[0][0] as { label?: string }[]).map((item) => item.label); +} + +describe('the reader', () => { + it('offers Edit on a part with a blob', async () => { + expect(await viewerMenu(makeEmail(part()))).toContain('edit'); + }); + + it('has none on a message marked as phishing', async () => { + expect(await viewerMenu(makeEmail(part(), { $seen: true, [PHISHING_KEYWORD]: true }))).not.toContain('edit'); + }); + + it('has none on a part with no blob, which is how a decrypted part reaches it', async () => { + expect(await viewerMenu(makeEmail(part({ blobId: '', tnefData: new Uint8Array([1]) })))).not.toContain('edit'); + }); + + it('has none for a file the shell keeps out', async () => { + expect(await viewerMenu(makeEmail(part({ name: 'run.bat' })))).not.toContain('edit'); + }); +}); + +function conversation(email: Email) { + render( + {}} + />, + ); +} + +describe('the conversation view', () => { + const edit = () => screen.queryByRole('button', { name: 'desktopEdit.edit_named' }); + + it('offers Edit on a part with a blob', async () => { + conversation(makeEmail(part())); + await waitFor(() => expect(edit()).not.toBeNull()); + }); + + it('has none on a message marked as phishing', async () => { + conversation(makeEmail(part(), { $seen: true, [PHISHING_KEYWORD]: true })); + await screen.findByText(/report\.docx/); + expect(edit()).toBeNull(); + }); + + it('has none on a part with no blob', async () => { + conversation(makeEmail(part({ blobId: '' }))); + await screen.findByText(/report\.docx/); + expect(edit()).toBeNull(); + }); + + it('has none for a file the shell keeps out', async () => { + conversation(makeEmail(part({ name: 'run.bat' }))); + await screen.findByText(/run\.bat/); + expect(edit()).toBeNull(); + }); + + it('has none in a browser', async () => { + window.bulwarkDesktop = undefined; + conversation(makeEmail(part())); + await screen.findByText(/report\.docx/); + expect(edit()).toBeNull(); + }); +}); diff --git a/components/email/__tests__/reader-attachments.test.tsx b/components/email/__tests__/reader-attachments.test.tsx index 67c6913e7..9bb922889 100644 --- a/components/email/__tests__/reader-attachments.test.tsx +++ b/components/email/__tests__/reader-attachments.test.tsx @@ -55,6 +55,24 @@ describe('the right-click menu in the desktop app', () => { expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open']); }); + it('Edit shows when the file can be edited and runs the action', async () => { + const onEdit = vi.fn(); + const { props, popup } = await openMenu((items) => items.find((i) => i.label === 'edit')?.id ?? null, { onEdit, canEdit: () => true }); + const labels = popup.mock.calls[0][0].flatMap((i: { label?: string }) => (i.label ? [i.label] : [])); + expect(labels).toEqual(['open', 'save_as', 'edit', 'copy']); + await waitFor(() => expect(onEdit).toHaveBeenCalledWith(props.attachments[0])); + }); + + it('Edit stays out for a file it cannot take', async () => { + const { popup } = await openMenu(() => null, { onEdit: vi.fn(), canEdit: () => false }); + expect(popup.mock.calls[0][0].map((i: { label?: string }) => i.label)).not.toContain('edit'); + }); + + it('Edit stays out with downloads off, whatever else says yes', async () => { + const { popup } = await openMenu(() => null, { onEdit: vi.fn(), canEdit: () => true, downloadsOff: true }); + expect(popup.mock.calls[0][0].map((i: { label?: string }) => i.label)).not.toContain('edit'); + }); + it('a browser keeps its own menu', async () => { window.bulwarkDesktop = undefined; renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn() }); diff --git a/components/email/__tests__/rich-text-editor-quote-caret.test.tsx b/components/email/__tests__/rich-text-editor-quote-caret.test.tsx new file mode 100644 index 000000000..37de365d6 --- /dev/null +++ b/components/email/__tests__/rich-text-editor-quote-caret.test.tsx @@ -0,0 +1,72 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import React from 'react'; +import type { Editor } from '@tiptap/core'; + +import { RichTextEditor } from '../rich-text-editor'; +import { serializeEditorContent } from '../quoted-html'; + +// A reply's quoted inline images load while the caret already waits in the +// body, and swapping them in re-sets the editor's content. That threw the caret +// onto the quote as a node, where the first key typed replaced the whole quote +// and the reply went out without the original or its images, or below the quote. + +const PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; +const IMAGE = 'data:image/png;base64,iVBORw0KGgo='; +const body = (src: string, below: string) => `

On Tuesday, C wrote:

Original text

logo
${below}`; + +async function openReply(below: string) { + let editor: Editor | null = null; + const element = (content: string) => ( + {}} onEditorReady={(ed) => { editor = ed as unknown as Editor; }} /> + ); + const view = render(element(body(PLACEHOLDER, below))); + await waitFor(() => expect(editor).not.toBeNull()); + const ed = editor as unknown as Editor; + // Where focus('start') puts it; focus itself waits for a frame. + act(() => { ed.commands.setTextSelection(1); }); + return { editor: ed, setContent: (content: string) => view.rerender(element(content)) }; +} + +const type = (editor: Editor, text: string) => act(() => { editor.view.dispatch(editor.state.tr.insertText(text)); }); + +describe.each([ + ['with a line below the quote', '

'], + ['with the quote at the very end', ''], +])('typing in a reply once its inline images have loaded, %s', (_name, below) => { + it('goes above the quote and keeps it and its image', async () => { + const { editor, setContent } = await openReply(below); + + setContent(body(IMAGE, below)); + type(editor, 'My answer'); + + const sent = serializeEditorContent(editor); + expect(editor.state.doc.child(0).textContent).toBe('My answer'); + expect(sent).toContain('Original text'); + expect(sent).toContain(IMAGE); + }); + + it('carries on where the user was already typing', async () => { + const { editor, setContent } = await openReply(below); + type(editor, 'Hel'); + + setContent(body(IMAGE, `${below}`).replace('

', '

Hel

')); + type(editor, 'lo'); + + expect(editor.state.doc.child(0).textContent).toBe('Hello'); + expect(serializeEditorContent(editor)).toContain('Original text'); + }); +}); + +describe('a body replaced by a shorter one', () => { + it('leaves the caret inside it', async () => { + const { editor, setContent } = await openReply('

A long line below the quote

'); + act(() => { editor.commands.setTextSelection(editor.state.doc.content.size - 2); }); + + setContent('

Short

'); + type(editor, '!'); + + expect(editor.state.doc.textContent).toContain('Short'); + expect(editor.state.doc.textContent).toContain('!'); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 753926c86..3409f77db 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input"; import { X, Paperclip, + FilePen, Send, Save, Check, @@ -178,6 +179,7 @@ import { noteOpenDraft } from "@/lib/desktop/window-restore"; import type { RescuedDraft } from "@/lib/popout/channel"; import { toUnicodeEmail } from "@/lib/idn"; import { setComposerFileTarget } from "@/lib/desktop/window-files"; +import { useComposerEdits } from "@/hooks/use-composer-edits"; // A Pro tab, message or folder dragged across a composer tab is on its way to // the pane or the split edge: those drags belong to the shell. There is no shell @@ -276,7 +278,7 @@ export interface ComposerDraftData { * File). Without these the composer starts with zero attachments and the * next save/send rebuilds the draft without them - silent data loss (#849). */ - attachments?: Array<{ blobId: string; name?: string; type?: string; size: number; cid?: string; disposition?: string; sourceAccountId?: string }>; + attachments?: Array<{ blobId: string; name?: string; type?: string; size: number; cid?: string; disposition?: string; sourceAccountId?: string; editBlocked?: boolean }>; /** The send options picked so far; absent, the defaults. Carried to another window. */ requestReadReceipt?: boolean; requestDsn?: boolean; @@ -481,6 +483,8 @@ interface EmailComposerProps { quoteWrapInBlockquote?: boolean; /** The original's headers: a reply or forward starts with its classification label. */ headers?: Record; + /** The original carries the phishing mark: its forwarded parts stay out of the desktop app's Edit. */ + phishingMarked?: boolean; /** The original's From, when `from` is left out (a forward as attachment): whether its label counts. */ originalFrom?: { email?: string; name?: string }[]; }; @@ -488,10 +492,14 @@ interface EmailComposerProps { type ComposerAttachment = { file?: File; + // The desktop app's Edit: which session's file this is. See useComposerEdits. + editSessionId?: string; // Login whose server holds `blobId`, set for parts of the original message // on a forward - the message may go out through another account, which // cannot resolve them. See rehomeForeignBlobs. sourceAccountId?: string; + // From a message marked as phishing: stays out of the desktop app's Edit. + editBlocked?: boolean; name: string; type: string; size: number; @@ -589,6 +597,7 @@ export function EmailComposer({ const tQuote = useTranslations('quote_header'); const tViewer = useTranslations('email_viewer'); const tClassification = useTranslations('classification'); + const tEdit = useTranslations('desktopEdit'); const tProofreading = useTranslations('proofreading'); const tOfflineDrafts = useTranslations('offlineDrafts'); const tOffline = useTranslations('offline'); @@ -1122,6 +1131,7 @@ export function EmailComposer({ // A forward stashed before its first save still holds the original // account's blobs: keep the tag so they are copied before saving. ...(att.sourceAccountId ? { sourceAccountId: att.sourceAccountId } : {}), + ...(att.editBlocked ? { editBlocked: true } : {}), fromDraftPart: true, })); } @@ -1150,6 +1160,7 @@ export function EmailComposer({ size: att.size, blobId: att.blobId, sourceAccountId: att.sourceAccountId ?? sourceAccountId ?? undefined, + ...(replyTo.phishingMarked ? { editBlocked: true } : {}), })); } return []; @@ -2754,6 +2765,26 @@ export function EmailComposer({ const [composerRootEl, setComposerRootEl] = useState(null); // The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen. + const loadAttachmentForEdit = useCallback(async (att: ComposerAttachment): Promise => { + if (att.file) return att.file; + // A forwarded part lives in its own login until the draft is saved through another. + const owner = att.sourceAccountId && att.sourceAccountId !== composerAccountId + ? useAuthStore.getState().getClientForAccount(att.sourceAccountId) + : composerClient; + if (!owner || !att.blobId) throw new Error('No content for this attachment'); + return owner.fetchBlob(att.blobId, att.name, att.type); + }, [composerClient, composerAccountId]); + const composerEdits = useComposerEdits({ + attachments, + setAttachments, + addFiles, + load: loadAttachmentForEdit, + account: composerAccountId ?? '', + draftId, + ownerAccountId: draftAccountId, + repliesTo: mode === 'reply' || mode === 'replyAll' ? sourceEmailId : undefined, + ready: !!client, + }); useEffect(() => setComposerFileTarget( (files) => void addFiles(files), () => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0, @@ -3663,6 +3694,7 @@ export function EmailComposer({ }, [attachments]); const [isWaitingForUploads, setIsWaitingForUploads] = useState(false); const sendCancelledRef = useRef(false); + const confirmingSendRef = useRef(false); // The user confirmed sending without a subject for this message. const emptySubjectConfirmedRef = useRef(false); // Placeholders the user chose to send as they are. The warning comes back @@ -3716,6 +3748,19 @@ export function EmailComposer({ } = {}) => { if (isSendingRef.current) return; + // Saved in another program and not on this message yet: not silently the older one. + // The wait lets a second click in, so that one goes no further. + if (composerEdits.holdsEdits()) { + if (confirmingSendRef.current) return; + confirmingSendRef.current = true; + try { + if (!(await composerEdits.confirmSend())) return; + } finally { + confirmingSendRef.current = false; + } + if (isSendingRef.current) return; + } + if (attachmentsRef.current.some(att => att.uploading)) { isSendingRef.current = true; setIsSending(true); @@ -4251,6 +4296,7 @@ export function EmailComposer({ } } + composerEdits.finish(); setTo([]); setCc([]); setBcc([]); @@ -4492,6 +4538,7 @@ export function EmailComposer({ attachment.abortController?.abort(); } setShowCloseDialog(false); + composerEdits.finish(); if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } @@ -5400,6 +5447,28 @@ export function EmailComposer({ ) : (
{label}
)} + {composerEdits.hasUpdate(att) && !att.uploading && ( + + )} + {!att.uploading && !att.error && composerEdits.canEditChip(att) && ( + + )} + {blobClient && attachment.blobId && !phishingMarked && canEditAttachment(attachment.name) && ( + + )} ); diff --git a/components/files/__tests__/wopi-base-path.test.tsx b/components/files/__tests__/wopi-base-path.test.tsx index c1facd996..0e2296298 100644 --- a/components/files/__tests__/wopi-base-path.test.tsx +++ b/components/files/__tests__/wopi-base-path.test.tsx @@ -2,6 +2,7 @@ import { render, renderHook, waitFor, cleanup } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useWopiStatus } from '@/hooks/use-wopi-status'; import { WopiEditor } from '@/components/files/wopi-editor'; +import { useAuthStore } from '@/stores/auth-store'; // Behind a reverse proxy at a sub-path (e.g. /webmail) a bare // fetch('/api/...') reaches the mail server instead of the webmail and comes @@ -26,9 +27,9 @@ afterEach(() => { describe('WOPI requests under a sub-path mount', () => { it('probes the editor status under the mount prefix', async () => { + useAuthStore.setState({ isAuthenticated: true, client: {} as never }); renderHook(() => useWopiStatus(true)); - await waitFor(() => expect(fetchMock).toHaveBeenCalled()); - expect(fetchMock.mock.calls[0][0]).toBe('/webmail/api/wopi/status'); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/webmail/api/wopi/status', undefined)); }); it('launches the editor under the mount prefix', async () => { diff --git a/components/icons/index.ts b/components/icons/index.ts index 91c72ea58..2068d465e 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -87,6 +87,7 @@ export { IconDatabase as Database, IconDownload as Download, IconEdit as EditIcon, + IconFilePencil as FilePen, IconDots as Ellipsis, IconEraser as Eraser, IconExternalLink as ExternalLink, diff --git a/components/layout/window-controls-host.tsx b/components/layout/window-controls-host.tsx index c829e15c1..1681da6f0 100644 --- a/components/layout/window-controls-host.tsx +++ b/components/layout/window-controls-host.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { applyControlsArea, watchTitlebarArea, watchWindowRows, type TitlebarArea } from "@/lib/window-controls-overlay"; +import { claimBrowserSignIn } from "@/lib/desktop/browser-sign-in"; /** * The window's controls over the app, with no title bar row: see @@ -10,6 +11,9 @@ import { applyControlsArea, watchTitlebarArea, watchWindowRows, type TitlebarAre * (admin, setup) get it too; there are no messages up there. */ export function WindowControlsHost() { + // In the first render of every page, before any frame exists: a frame can reach the + // bridge, and whoever comes after this gets nothing. + if (typeof window !== "undefined") claimBrowserSignIn(); const [area, setArea] = useState(null); useEffect(() => watchTitlebarArea((next) => { diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 0a37a0835..11d4246c0 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -129,6 +129,7 @@ import { isFilePreviewable } from "@/lib/file-preview"; import { prepareQuickReply, type QuickReplyQuestion } from "@/lib/outgoing/quick-reply"; import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; import { isPhishingMarked } from "@/lib/phishing-links"; +import { phishingMarkedNow } from "@/hooks/use-phishing-report"; import { automaticLabel, classifyOutgoing, labelSourceHeaders, reopenedLabelChoice } from "@/lib/outgoing/classification"; import enMessages from "@/locales/en/common.json"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; @@ -1481,6 +1482,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { bcc: selectedEmail.bcc, deliveredTo: deliveryAddresses(selectedEmail.headers), subject: selectedEmail.subject, + phishingMarked: phishingMarkedNow(selectedEmail), ...getQuoteBodies(selectedEmail), receivedAt: selectedEmail.receivedAt, // The login that holds the message, so the reply defaults to that @@ -3214,6 +3216,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { attachments: [payload.attachment], headers: labelSourceHeaders(email.headers), originalFrom: email.from, + // The login holding the message, whose blob this attachment is. + accountId: email.sourceClientAccountId ?? viewingAccountId ?? undefined, }, }); setComposerMode('forward'); @@ -5188,6 +5192,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { bcc: composerSourceEmail.bcc, deliveredTo: deliveryAddresses(composerSourceEmail.headers), subject: composerSourceEmail.subject, + phishingMarked: phishingMarkedNow(composerSourceEmail), ...getQuoteBodies(composerSourceEmail), receivedAt: composerSourceEmail.receivedAt, // The login that holds the message, so the reply diff --git a/components/pro/__tests__/pro-email-reply-account.test.tsx b/components/pro/__tests__/pro-email-reply-account.test.tsx new file mode 100644 index 000000000..d0d514717 --- /dev/null +++ b/components/pro/__tests__/pro-email-reply-account.test.tsx @@ -0,0 +1,137 @@ +import { render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; + +/** + * A reply, forward or forward-as-attachment from a Pro message view names the + * login holding the message. The composer defaults its From to that account and + * copies the original's parts from it; without the login it takes the message + * for the active account's, so the reply goes out from the wrong account, or + * fails on a blobId that account doesn't have (#1511). + */ + +const viewerProps = vi.hoisted(() => ({ current: null as Record | null })); + +vi.mock('@/components/email/email-viewer', () => ({ + EmailViewer: (props: Record) => { + viewerProps.current = props; + return null; + }, +})); +vi.mock('@/components/error', () => ({ + ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), + EmailViewerErrorFallback: () => null, +})); +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); +vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } })); +vi.mock('@/hooks/use-pro-multi-account-identities', async (importOriginal) => ({ + ...await importOriginal(), + useProMultiAccountIdentities: () => ({ enabled: true, groups: [], allIdentities: [] }), +})); + +const { openComposeTab } = vi.hoisted(() => ({ openComposeTab: vi.fn() })); + +function selectorStore(state: T) { + const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state); + hook.getState = () => state; + return hook; +} + +const auth = vi.hoisted(() => ({ + state: { client: null as unknown, activeAccountId: 'login-a', getClientForAccount: () => undefined }, +})); +vi.mock('@/stores/auth-store', () => ({ useAuthStore: selectorStore(auth.state) })); + +const MESSAGE = vi.hoisted(() => ({ + id: 'eaaaaab', + blobId: 'blob-of-message', + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + from: [{ email: 'sender@example.com', name: 'Sender' }], + to: [{ email: 'b@example.org' }], + subject: 'Hello', + receivedAt: '2026-01-01T00:00:00Z', + bodyValues: { '1': { value: '' } }, + textBody: [], + htmlBody: [{ partId: '1', type: 'text/html' }], + attachments: [{ blobId: 'blob-of-logo', name: 'logo.png', type: 'image/png', size: 10, cid: 'logo', disposition: 'inline' }], +})); + +vi.mock('@/stores/email-store', () => ({ + resolveEmailBlobContext: (_email: unknown, client: unknown) => ({ client, clientAccountId: auth.state.activeAccountId }), + useEmailStore: selectorStore({ + fetchEmailContent: async () => MESSAGE, + deleteEmail: async () => {}, + markAsRead: async () => {}, + toggleStar: async () => {}, + moveToMailbox: async () => {}, + setEmailKeywordsLocal: () => {}, + mailboxes: [], + emails: [], + selectedEmail: null, + }), +})); +vi.mock('@/stores/identity-store', () => ({ useIdentityStore: selectorStore({ identities: [] }) })); +vi.mock('@/stores/settings-store', () => ({ useSettingsStore: selectorStore({ emailDownloadTemplate: '' }) })); +vi.mock('@/stores/pro-tab-store', () => ({ + useProTabStore: selectorStore({ openComposeTab, closeTab: () => {}, updateTabTitle: () => {} }), +})); + +import { ProEmailView } from '../pro-email-tab-body'; + +const ACTIONS = ['onReply', 'onReplyAll', 'onForward', 'onForwardAsAttachment'] as const; + +async function open(props: { clientAccountId?: string; accountId?: string; override?: boolean }) { + const client = { getEmail: vi.fn().mockResolvedValue(MESSAGE), getUsername: () => 'b@example.org', getAccountId: () => 'jmap-b' }; + const { override = true, ...rest } = props; + if (!override) auth.state.client = client; + const view = render(); + await waitFor(() => expect(viewerProps.current?.email).toBeTruthy()); + return { ...view, client }; +} + +function composeFrom(action: typeof ACTIONS[number]) { + openComposeTab.mockClear(); + (viewerProps.current![action] as () => void)(); + expect(openComposeTab).toHaveBeenCalledTimes(1); + return openComposeTab.mock.calls[0][0].replyTo as Record; +} + +describe('compose actions of a Pro message view', () => { + beforeEach(() => { + viewerProps.current = null; + openComposeTab.mockClear(); + auth.state.activeAccountId = 'login-a'; + auth.state.client = null; + }); + + it.each(ACTIONS)('%s of another login names that login', async (action) => { + await open({ clientAccountId: 'login-b' }); + + expect(composeFrom(action).accountId).toBe('login-b'); + }); + + it.each(ACTIONS)('%s of a shared folder of another login names the login, not the owner', async (action) => { + await open({ clientAccountId: 'login-b', accountId: 'jmap-team' }); + + expect(composeFrom(action).accountId).toBe('login-b'); + }); + + it.each(ACTIONS)('%s of the active login names the active login', async (action) => { + await open({ override: false }); + + expect(composeFrom(action).accountId).toBe('login-a'); + }); + + it('keeps naming the login the message was read in after the active account changes', async () => { + // A message of b isn't foreign while b is active, so it carries no stamp. + auth.state.activeAccountId = 'login-b'; + const { rerender, client } = await open({ clientAccountId: 'login-b' }); + + auth.state.activeAccountId = 'login-a'; + rerender(); + + expect(composeFrom('onReply').accountId).toBe('login-b'); + }); +}); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 024742440..569b13b0a 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -34,6 +34,7 @@ import { takeTabReply, useNotificationTabReply } from '@/lib/pwa-notify/tab-repl import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope'; import { canDownload } from '@/lib/device-policy'; +import { phishingMarkedNow } from '@/hooks/use-phishing-report'; import { useOpenEmailInWindow } from '@/hooks/use-popouts-available'; import { isFilePreviewable } from '@/lib/file-preview'; import { useDesktopCommands } from '@/lib/desktop/commands'; @@ -111,8 +112,19 @@ export function stampProSource(email: Email, source: EmailBlobContext, loginId: return { ...email, sourceClientAccountId: login, sourceAccountId: account }; } -function buildReplyContext(email: Email): ProReplyContext { +/** + * The login holding the message, for ProReplyContext.accountId: the composer + * defaults its From to that account and copies the original's parts from it + * (#1104, #1511). email.accountId can't say: it is for display, and a message + * read through client.getEmail() has none. + */ +function replyLogin(email: Email, source: EmailWriteSource): string | undefined { + return email.sourceClientAccountId ?? source.clientAccountId; +} + +function buildReplyContext(email: Email, source: EmailWriteSource): ProReplyContext { return { + phishingMarked: phishingMarkedNow(email), from: email.from, replyToAddresses: email.replyTo, to: email.to, @@ -122,7 +134,7 @@ function buildReplyContext(email: Email): ProReplyContext { subject: email.subject, ...getQuoteBodies(email), receivedAt: email.receivedAt, - accountId: email.accountId, + accountId: replyLogin(email, source), attachments: email.attachments, messageId: email.messageId, inReplyTo: email.inReplyTo, @@ -285,7 +297,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, if (!email) return; openComposeTab({ mode: 'reply', - replyTo: buildReplyContext(email), + replyTo: buildReplyContext(email, writeSource), sourceEmailId: email.id, sourceEmailSource: writeSource, initialDraftText: draftText, @@ -305,7 +317,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, if (!email) return; openComposeTab({ mode: 'replyAll', - replyTo: buildReplyContext(email), + replyTo: buildReplyContext(email, writeSource), sourceEmailId: email.id, sourceEmailSource: writeSource, title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')), @@ -316,7 +328,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, if (!email) return; openComposeTab({ mode: 'forward', - replyTo: buildReplyContext(email), + replyTo: buildReplyContext(email, writeSource), sourceEmailId: email.id, sourceEmailSource: writeSource, title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')), @@ -355,6 +367,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, attachments: [payload.attachment], headers: labelSourceHeaders(email.headers), originalFrom: email.from, + accountId: replyLogin(email, writeSource), }, sourceEmailId: email.id, sourceEmailSource: writeSource, diff --git a/components/providers/__tests__/notification-actions-host-quiet.test.tsx b/components/providers/__tests__/notification-actions-host-quiet.test.tsx new file mode 100644 index 000000000..d7a30d756 --- /dev/null +++ b/components/providers/__tests__/notification-actions-host-quiet.test.tsx @@ -0,0 +1,58 @@ +import { render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NotificationActionsHost } from '../notification-actions-host'; + +// The worker shows nothing for a login with email notifications off or do not +// disturb on. Each login has its own settings, so the answer goes per login. + +const writeLoginQuiet = vi.fn(async (..._args: unknown[]) => {}); +let state = { isAuthenticated: true, username: 'me@example.org' as string | null, emailNotificationsEnabled: true, doNotDisturb: false }; + +vi.mock('@/lib/push-preferences', () => ({ writeLoginQuiet: (...args: unknown[]) => writeLoginQuiet(...args), PUSH_STATE_CACHE: 'x' })); +vi.mock('@/lib/pwa-notify/worker-state', () => ({ writeNotificationActionPrefs: async () => {} })); +vi.mock('@/lib/pwa-notify/page-actions', () => ({ isNotificationActionRequest: () => false, runNotificationAction: async () => {} })); +vi.mock('@/lib/pwa-notify/plugin-veto', () => ({ pluginsMayVeto: () => false })); +vi.mock('@/lib/offline/offline-mode', () => ({ scheduleFlushAll: () => {} })); +vi.mock('@/lib/archive-gate', () => ({ useArchiveActionEnabled: () => false })); +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); +vi.mock('@/stores/auth-store', () => ({ useAuthStore: (select: (s: typeof state) => unknown) => select(state) })); +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (select: (s: typeof state & { archiveMode: string }) => unknown) => select({ ...state, archiveMode: 'single' }), +})); +vi.mock('@/stores/plugin-store', () => ({ usePluginStore: (select: (s: { plugins: unknown[] }) => unknown) => select({ plugins: [] }) })); +vi.mock('@/stores/account-store', () => { + const useAccountStore = (select: (s: { accounts: unknown[] }) => unknown) => select({ accounts: [] }); + useAccountStore.getState = () => ({ accounts: [] }); + return { useAccountStore }; +}); + +beforeEach(() => { + writeLoginQuiet.mockClear(); + state = { isAuthenticated: true, username: 'me@example.org', emailNotificationsEnabled: true, doNotDisturb: false }; +}); + +describe('what the page tells the worker about a login', () => { + it('not quiet with everything on', () => { + render(); + expect(writeLoginQuiet).toHaveBeenCalledWith('me@example.org', false); + }); + + it('quiet with email notifications off', () => { + state.emailNotificationsEnabled = false; + render(); + expect(writeLoginQuiet).toHaveBeenCalledWith('me@example.org', true); + }); + + it('quiet with do not disturb on', () => { + state.doNotDisturb = true; + render(); + expect(writeLoginQuiet).toHaveBeenCalledWith('me@example.org', true); + }); + + it('nothing while signed out', () => { + state.isAuthenticated = false; + state.username = null; + render(); + expect(writeLoginQuiet).not.toHaveBeenCalled(); + }); +}); diff --git a/components/providers/notification-actions-host.tsx b/components/providers/notification-actions-host.tsx index 4077951d3..7d1a004e7 100644 --- a/components/providers/notification-actions-host.tsx +++ b/components/providers/notification-actions-host.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import { useArchiveActionEnabled } from '@/lib/archive-gate'; import { scheduleFlushAll } from '@/lib/offline/offline-mode'; import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { writeLoginQuiet } from '@/lib/push-preferences'; import { isNotificationActionRequest, runNotificationAction } from '@/lib/pwa-notify/page-actions'; import { pluginsMayVeto } from '@/lib/pwa-notify/plugin-veto'; import { writeNotificationActionPrefs } from '@/lib/pwa-notify/worker-state'; @@ -42,6 +43,16 @@ export function NotificationActionsHost() { }); }, [isAuthenticated, archiveEnabled, archiveMode, mayVeto, archive, markRead, reply, replyPlaceholder]); + // Email notifications off or do not disturb on, for the login on screen: the + // worker shows nothing for that login's mail. The label a push carries is this + // name (lib/web-push.ts), and each login has its own settings. + const username = useAuthStore((s) => s.username); + const quiet = useSettingsStore((s) => !s.emailNotificationsEnabled || s.doNotDisturb); + useEffect(() => { + if (!isAuthenticated || !username) return; + void writeLoginQuiet(username, quiet); + }, [isAuthenticated, username, quiet]); + // Which logins this tab holds: if it stays silent on a click for one of them, // the worker opens the message rather than go round its plugins. const loginKey = useMemo(() => accounts.map((a) => `${a.serverUrl}\n${a.username}`).join('\n\n'), [accounts]); diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx index c88ce9361..b591eaa8c 100644 --- a/components/push-notification-prompt.tsx +++ b/components/push-notification-prompt.tsx @@ -19,8 +19,8 @@ import { resyncWebPush, waitForWebPushResync, } from "@/lib/web-push"; +import { webPushErrorKey } from "@/lib/web-push-errors"; import { resolveActiveRelayUrl } from "@/lib/push-relays"; -import { jmapErrorType } from "@/lib/jmap/jmap-error"; import { isPWAInstallPromptVisible, PWA_INSTALL_PROMPT_VISIBILITY_EVENT, @@ -310,11 +310,9 @@ export function PushNotificationPrompt() { }); setShowPrompt(false); } catch (err) { - // Readable text for the server refusals a user can act on. - const type = jmapErrorType(err); - if (type === "forbidden") setError(tPush("error_forbidden")); - else if (type === "overQuota") setError(tPush("error_over_quota")); - else setError(err instanceof Error ? err.message : "Failed to enable push"); + // Readable text for what a user can act on. + const key = webPushErrorKey(err); + setError(key ? tPush(key) : err instanceof Error ? err.message : "Failed to enable push"); } finally { setIsEnabling(false); } diff --git a/components/settings/__tests__/notification-settings.test.tsx b/components/settings/__tests__/notification-settings.test.tsx index 5b6a4b5e2..0913b7204 100644 --- a/components/settings/__tests__/notification-settings.test.tsx +++ b/components/settings/__tests__/notification-settings.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; import { act } from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'; import { NotificationSettings } from '../notification-settings'; import { useSettingsStore } from '@/stores/settings-store'; import { useAuthStore } from '@/stores/auth-store'; @@ -137,6 +137,25 @@ describe('NotificationSettings - pushNotifyInboxOnly', () => { }); }); +describe('NotificationSettings - demo mode', () => { + beforeEach(() => { + useAuthStore.setState({ client: fakeClient, username: 'me@example.com', isDemoMode: true }); + vi.clearAllMocks(); + }); + + afterEach(() => { + useAuthStore.setState({ isDemoMode: false }); + }); + + it('says push is not available and offers no switch that would call the relay', async () => { + const { findByText, getAllByRole } = render(); + await findByText('push.status_demo'); + const pushSwitch = getAllByRole('switch').find((el) => (el as HTMLButtonElement).disabled); + expect(pushSwitch).toBeTruthy(); + expect(webPush.enableWebPush).not.toHaveBeenCalled(); + }); +}); + describe('NotificationSettings - mailboxes shared by ACL', () => { const mailbox = (id: string, accountId: string, isShared: boolean) => ({ id, originalId: id, name: id, accountId, isShared }) as unknown as Mailbox; diff --git a/components/settings/app-lock-settings.tsx b/components/settings/app-lock-settings.tsx index bdc126f30..d68ac012c 100644 --- a/components/settings/app-lock-settings.tsx +++ b/components/settings/app-lock-settings.tsx @@ -39,6 +39,7 @@ function useLockFacts() { /** The app lock's section on the General page: it has to exist on any server. */ export function AppLockSettings() { const t = useTranslations("appLock"); + const tEdit = useTranslations("desktopEdit"); const { appName } = useConfig(); const { on, required, timeout, update } = useLockFacts(); const [pending, setPending] = useState(null); @@ -178,6 +179,10 @@ export function AppLockSettings() { )} + {on && getDesktopBridge()?.edit && ( +

{tEdit("lock_note")}

+ )} + { + const key = webPushErrorKey(error); + if (key) return tPush(key); + return error instanceof Error && error.message ? error.message : String(error); + }; + const failureOf = (account: (typeof accounts)[number], error: unknown): FailedAccount => ({ + id: account.id, + name: account.displayName || account.label || account.username, + email: account.email || account.username, + reason: failureReason(error), + }); const accounts = useAccountStore(s => s.accounts); // Re-read the client map whenever a connection appears or drops. const revision = useAuthStore(s => s.connectedAccountsRevision); + const isDemoMode = useAuthStore(s => s.isDemoMode); const [enabled, setEnabled] = useState>({}); const [chosen, setChosen] = useState>(new Set()); const [busy, setBusy] = useState(false); @@ -59,7 +68,7 @@ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: stri useEffect(() => { void refresh(); }, [refresh, revision]); - if (!isWebPushSupported() || connected.length < 2) return null; + if (!isWebPushSupported() || isDemoMode || connected.length < 2) return null; const toggle = (id: string, on: boolean) => setChosen(prev => { const next = new Set(prev); @@ -95,11 +104,15 @@ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: stri }; })); } else { - // Teardown swallows its own failures, so every account ends up local-off. + // Every account ends up off here; one the server or relay couldn't be + // told about is named, since it may keep getting notifications for days. + const failed: FailedAccount[] = []; for (const account of targets) { - await disableWebPush({ client: auth.getClientForAccount(account.id)!, relayBaseUrl }); + await disableWebPush({ client: auth.getClientForAccount(account.id)!, relayBaseUrl }) + .catch((error: unknown) => { failed.push(failureOf(account, error)); }); } setNotice(t('result_disabled', { count: targets.length })); + setFailures(failed); } setChosen(new Set()); } finally { diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx index f94cd4134..45923d8d9 100644 --- a/components/settings/notification-settings.tsx +++ b/components/settings/notification-settings.tsx @@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import { useEmailStore } from '@/stores/email-store'; import { useIdentityStore } from '@/stores/identity-store'; -import { jmapErrorType } from '@/lib/jmap/jmap-error'; import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section'; import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound'; import type { NotificationSoundChoice } from '@/lib/notification-sound'; @@ -27,6 +26,7 @@ import { revokePushDevice, serverSupportsEmailPush, } from '@/lib/web-push'; +import { webPushErrorKey } from '@/lib/web-push-errors'; import type { PushDevice } from '@/lib/web-push'; import { IS_LITE } from '@/lib/lite'; import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode'; @@ -72,7 +72,10 @@ export function NotificationSettings() { const username = useAuthStore((s) => s.username); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); - const supported = typeof window !== 'undefined' && isWebPushSupported(); + // The demo client has no mail server to subscribe with. Enabling would get as + // far as a relay record and a browser subscription, then fail. + const isDemoMode = useAuthStore((s) => s.isDemoMode); + const supported = typeof window !== 'undefined' && isWebPushSupported() && !isDemoMode; // "Inbox only" is enforced by the server's emailPush filter; without that // capability (Stalwart < 0.16.16) the toggle would do nothing. const inboxOnlyAvailable = supported && !!client && serverSupportsEmailPush(client); @@ -145,7 +148,7 @@ export function NotificationSettings() { // In the static Lite build push is off by design (no service worker, see // components/service-worker-registration.tsx), not a browser limitation. const statusDescription = pushStatus.kind === 'unsupported' - ? (IS_LITE ? t('push.status_lite') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`) + ? (IS_LITE ? t('push.status_lite') : isDemoMode ? t('push.status_demo') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`) : noRelay && !pushEnabled ? tThirdParty('push_needs_relay') : busy @@ -171,14 +174,9 @@ export function NotificationSettings() { // The refusals a user can do something about get readable text; anything // else keeps the error's own message. const pushErrorMessage = (err: unknown, fallback: string) => { - switch (jmapErrorType(err)) { - case 'forbidden': - return t('push.error_forbidden'); - case 'overQuota': - return t('push.error_over_quota'); - default: - return err instanceof Error ? err.message : fallback; - } + const key = webPushErrorKey(err); + if (key) return t(`push.${key}`); + return err instanceof Error ? err.message : fallback; }; // `forceRecreate` is what the Re-register button is for: without it the enable @@ -265,10 +263,7 @@ export function NotificationSettings() { await disableWebPush({ client, relayBaseUrl: activeRelayUrl }); setPushStatus({ kind: 'idle' }); } catch (err) { - setPushStatus({ - kind: 'error', - message: err instanceof Error ? err.message : 'Failed to disable push', - }); + setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to disable push') }); } finally { await refreshDevices(); } @@ -290,10 +285,7 @@ export function NotificationSettings() { await revokePushDevice({ client, device, relayBaseUrl: activeRelayUrl }); if (device.isThisDevice) setPushStatus({ kind: 'idle' }); } catch (err) { - setPushStatus({ - kind: 'error', - message: err instanceof Error ? err.message : 'Failed to revoke device', - }); + setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to revoke device') }); } finally { setRevokingId(null); await refreshDevices(); diff --git a/desktop-policies/Bulwark.admx b/desktop-policies/Bulwark.admx index d4b1b0817..7eb70a262 100644 --- a/desktop-policies/Bulwark.admx +++ b/desktop-policies/Bulwark.admx @@ -209,6 +209,18 @@ + + + + + + + + + + + + diff --git a/desktop-policies/de-DE/Bulwark.adml b/desktop-policies/de-DE/Bulwark.adml index 273a8c7b9..5f6035b00 100644 --- a/desktop-policies/de-DE/Bulwark.adml +++ b/desktop-policies/de-DE/Bulwark.adml @@ -56,6 +56,8 @@ Eine JSON-Regel pro Zeile, im Format von Chrome: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage. Websites, die den Zertifikatspeicher des Systems verwenden dürfen Die Bulwark-Server, nach https-Adresse, deren Plugins mit den Zertifikaten im Speicher des Betriebssystems signieren und entschlüsseln dürfen, ohne dass der Benutzer für jede Website gefragt wird. Eine pro Zeile. + Anmeldung über den Browser anbieten + Wenn aktiviert oder nicht konfiguriert, bietet die Anmeldeseite „Mit Ihrem Browser anmelden“ an, damit Benutzer dort ihren Passwort-Manager, Passkeys und Ihr Single Sign-on verwenden können. Wenn deaktiviert, fehlt die Schaltfläche und die App lehnt den Vorgang ab. Das Formular in der App bleibt, sofern der Server nicht nur OAuth erlaubt. Auf dem Gerät behaltene E-Mails löschen Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. diff --git a/desktop-policies/en-US/Bulwark.adml b/desktop-policies/en-US/Bulwark.adml index a80f0ddca..4b276fb70 100644 --- a/desktop-policies/en-US/Bulwark.adml +++ b/desktop-policies/en-US/Bulwark.adml @@ -56,6 +56,8 @@ One JSON rule per line, in the format Chrome uses: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. The app then picks the matching certificate for that site without asking. Sites that may use the system certificate store The Bulwark servers, by https address, whose plugins may sign and decrypt with the certificates in the operating system's store, without the user being asked for each site. One per line. + Offer sign-in through the browser + When enabled or not configured, the login page offers "Sign in with your browser", so people can use their password manager, passkeys and your single sign-on there. When disabled, the button is gone and the app refuses the flow. The form in the app stays, unless the server only allows OAuth. Delete mail kept on the device Set this to a new value, such as today's date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. diff --git a/desktop-policies/org.bulwarkmail.desktop.mobileconfig b/desktop-policies/org.bulwarkmail.desktop.mobileconfig index 474b9f20a..bb3b0d05c 100644 --- a/desktop-policies/org.bulwarkmail.desktop.mobileconfig +++ b/desktop-policies/org.bulwarkmail.desktop.mobileconfig @@ -61,6 +61,8 @@ https://mail.example.com + BrowserSignIn + Recommended UpdateChannel diff --git a/desktop-policies/policies.json b/desktop-policies/policies.json index 965e905fb..751f93131 100644 --- a/desktop-policies/policies.json +++ b/desktop-policies/policies.json @@ -25,6 +25,7 @@ "KeystoreAllowedOrigins": [ "https://mail.example.com" ], + "BrowserSignIn": true, "Recommended": { "UpdateChannel": "stable" } diff --git a/hooks/__tests__/use-composer-edits.test.tsx b/hooks/__tests__/use-composer-edits.test.tsx new file mode 100644 index 000000000..6e234bee2 --- /dev/null +++ b/hooks/__tests__/use-composer-edits.test.tsx @@ -0,0 +1,334 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const policy = vi.hoisted(() => ({ allowed: true })); +const toastError = vi.hoisted(() => vi.fn()); +const forbidden = vi.hoisted(() => new Set()); + +vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); +vi.mock('@/stores/toast-store', () => ({ toast: { error: toastError, success: vi.fn() } })); +vi.mock('@/hooks/use-device-policy', () => ({ + useDevicePolicy: () => ({ noDownloads: !policy.allowed, noExport: false }), +})); +vi.mock('@/lib/device-policy', () => ({ + canDownload: (account?: string) => policy.allowed && !forbidden.has(account ?? ''), + canExport: () => true, + deviceSubjectOf: () => null, +})); +vi.mock('@/lib/offline/offline-mode', () => ({ scopeDecided: () => true })); +vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ activeAccountId: 'acct' }) } })); + +import { useComposerEdits, type EditableAttachment } from '../use-composer-edits'; +import { parkFilesForReply, readEditedFile, resetEditSessions, useEditSessions } from '@/lib/desktop/edit-attachments'; + +function fakeBridge() { + return { + canEdit: vi.fn((name: string) => !name.endsWith('.exe')), + begin: vi.fn(async () => ({ ok: true as const, ticket: 't1' })), + open: vi.fn(async () => ({ ok: true as const, id: 's1' })), + reopen: vi.fn(async () => ({ ok: true as const, id: 's1' })), + list: vi.fn(async () => []), + settle: vi.fn(async () => useEditSessions.getState().sessions), + read: vi.fn(async () => ({ ok: true as const, name: 'a.docx', type: 'x/y', data: new Uint8Array([1, 2]), hash: 'h2' })), + taken: vi.fn(async () => {}), + release: vi.fn(async () => {}), + discard: vi.fn(async () => ({ held: [] })), + forceDiscard: vi.fn(async () => ({ held: [] })), + askClear: vi.fn(async () => true), + clear: vi.fn(async () => ({ held: [] })), + wipe: vi.fn(async () => ({ held: [] })), + shown: vi.fn(), + setSource: vi.fn(), + claim: vi.fn(), + onChange: vi.fn(() => () => {}), + }; +} + +let bridge: ReturnType; +let confirm: ReturnType; + +const session = (over: Partial<{ id: string; name: string; source: string; changed: boolean; claimed: boolean; hash: string }> = {}) => ({ + id: 's1', name: 'a.docx', type: 'x/y', account: 'acct', source: '{"kind":"draft","draftId":"d1"}', changed: false, version: 0, hash: 'current', claimed: true, held: false, savedAs: false, lastChance: false, ...over, +}); + +const chipNamed = (name: string): EditableAttachment => ({ name, type: 'x/y', blobId: 'b' }); + +interface Setup { + start: EditableAttachment[]; + draftId?: string | null; + repliesTo?: string; + /** What the composer's upload does with a file. */ + upload?: (file: File) => Partial; +} + +function setup({ start, draftId = 'd1', repliesTo, upload = () => ({ blobId: 'new-blob' }) }: Setup) { + const load = vi.fn(async () => new Blob(['x'])); + bridge.list.mockResolvedValue(useEditSessions.getState().sessions as never); + return renderHook(() => { + const [attachments, setAttachments] = useState(start); + const addFiles = async (files: File[]) => { + setAttachments((list) => [...list, ...files.map((file) => ({ file, name: file.name, type: file.type, uploading: false, ...upload(file) }))]); + }; + const edits = useComposerEdits({ attachments, setAttachments, addFiles, load, account: 'acct', draftId, repliesTo, ready: true }); + return { attachments, setAttachments, edits }; + }); +} + +beforeEach(() => { + policy.allowed = true; + bridge = fakeBridge(); + confirm = vi.fn(async () => 0); + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, edit: bridge, dialogs: { confirm } }; + resetEditSessions(); + toastError.mockClear(); +}); + +afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('updating a chip', () => { + const chip: EditableAttachment = { name: 'a.docx', type: 'x/y', blobId: 'old-blob', editSessionId: 's1' }; + + it('takes the old file off only once the new one is up, and marks that version as taken', async () => { + useEditSessions.setState({ sessions: [session({ changed: true })] }); + const { result } = setup({ start: [chip] }); + expect(result.current.edits.hasUpdate(result.current.attachments[0])).toBe(true); + await act(() => result.current.edits.update(result.current.attachments[0])); + await waitFor(() => expect(result.current.attachments).toHaveLength(1)); + const [only] = result.current.attachments; + expect(only.blobId).toBe('new-blob'); + expect(only.file?.name).toBe('a.docx'); + await waitFor(() => expect(only.editSessionId ?? result.current.attachments[0].editSessionId).toBe('s1')); + await waitFor(() => expect(bridge.taken).toHaveBeenCalledWith('s1', 'h2')); + // Stays held by the composer throughout: it's the same session. + expect(bridge.discard).not.toHaveBeenCalled(); + }); + + it('keeps the old file and the offer when the upload fails', async () => { + useEditSessions.setState({ sessions: [session({ changed: true })] }); + const { result } = setup({ start: [chip], upload: () => ({ error: true }) }); + await act(() => result.current.edits.update(result.current.attachments[0])); + await waitFor(() => expect(result.current.attachments).toHaveLength(1)); + expect(result.current.attachments[0].blobId).toBe('old-blob'); + expect(result.current.edits.hasUpdate(result.current.attachments[0])).toBe(true); + expect(bridge.taken).not.toHaveBeenCalled(); + }); + + it('says so when the program still holds the file', async () => { + bridge.read.mockResolvedValueOnce({ ok: false, reason: 'busy' } as never); + useEditSessions.setState({ sessions: [session({ changed: true })] }); + const { result } = setup({ start: [chip] }); + await act(() => result.current.edits.update(result.current.attachments[0])); + expect(toastError).toHaveBeenCalledWith('read_busy'); + expect(result.current.attachments[0].blobId).toBe('old-blob'); + }); + + it('refuses when the organisation has said no since', async () => { + policy.allowed = false; + useEditSessions.setState({ sessions: [session({ changed: true })] }); + const { result } = setup({ start: [chip] }); + await act(() => result.current.edits.update(result.current.attachments[0])); + expect(bridge.read).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('blocked_policy'); + expect(result.current.attachments).toHaveLength(1); + }); +}); + +describe('starting from a chip', () => { + it('fetches a server part, and the chip takes the session on', async () => { + const { result } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b1' }] }); + await act(() => result.current.edits.startEdit(result.current.attachments[0])); + expect(bridge.begin).toHaveBeenCalledWith('blob:acct:b1'); + await waitFor(() => expect(result.current.attachments[0].editSessionId).toBe('s1')); + expect(bridge.claim).toHaveBeenCalledWith('s1', true); + const [, file] = bridge.open.mock.calls[0] as unknown as [string, { source: string }]; + expect(JSON.parse(file.source)).toEqual({ kind: 'draft', draftId: 'd1' }); + }); + + it('a chip with a session goes back to that session', async () => { + const { result } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b1', editSessionId: 's1' }] }); + await act(() => result.current.edits.startEdit(result.current.attachments[0])); + expect(bridge.begin).toHaveBeenCalledWith('session:s1'); + }); + + it('offers it only for what the shell and the organisation allow', () => { + const { result } = setup({ start: [] }); + expect(result.current.edits.canEditChip(chipNamed('a.docx'))).toBe(true); + expect(result.current.edits.canEditChip(chipNamed('a.exe'))).toBe(false); + }); + + it('offers nothing under a policy', () => { + policy.allowed = false; + const { result } = setup({ start: [] }); + expect(result.current.edits.canEditChip(chipNamed('a.docx'))).toBe(false); + }); +}); + +describe('holding sessions', () => { + it('claims what is on a chip, follows the draft id, and lets go when it closes, which is no end', async () => { + const { result, rerender, unmount } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b', editSessionId: 's1' }] }); + expect(bridge.claim).toHaveBeenCalledWith('s1', true); + expect(bridge.setSource).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'draft', draftId: 'd1' })); + rerender(); + expect(result.current.attachments).toHaveLength(1); + unmount(); + expect(bridge.claim).toHaveBeenLastCalledWith('s1', false); + expect(bridge.discard).not.toHaveBeenCalled(); + }); + + it('a chip taken off leaves the decision to main, which knows about a save from a moment ago', async () => { + useEditSessions.setState({ sessions: [session({ id: 's1' }), session({ id: 's2', name: 'b.docx', changed: true })] }); + const { result } = setup({ start: [ + { name: 'a.docx', type: 'x/y', blobId: 'b', editSessionId: 's1' }, + { name: 'b.docx', type: 'x/y', blobId: 'c', editSessionId: 's2' }, + ] }); + act(() => result.current.setAttachments([])); + await waitFor(() => expect(bridge.release).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'file' }))); + expect(bridge.release).toHaveBeenCalledWith('s2', JSON.stringify({ kind: 'file' })); + expect(bridge.discard).not.toHaveBeenCalled(); + }); + + it('a draft opened again finds its session by id and name', async () => { + useEditSessions.setState({ sessions: [session({ claimed: false, changed: true })] }); + const { result } = setup({ start: [{ name: 'other.pdf', type: 'x/y', blobId: 'z' }, { name: 'a.docx', type: 'x/y', blobId: 'b' }] }); + await waitFor(() => expect(result.current.attachments[1].editSessionId).toBe('s1')); + expect(result.current.attachments[0].editSessionId).toBeUndefined(); + expect(result.current.edits.hasUpdate(result.current.attachments[1])).toBe(true); + }); + + it('another draft leaves it alone', async () => { + useEditSessions.setState({ sessions: [session({ claimed: false, source: '{"kind":"draft","draftId":"other"}' })] }); + const { result } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b' }] }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(result.current.attachments[0].editSessionId).toBeUndefined(); + }); +}); + +describe('sending', () => { + const chip: EditableAttachment = { name: 'a.docx', type: 'x/y', blobId: 'b', editSessionId: 's1' }; + + it('goes on without a question when nothing waits', async () => { + useEditSessions.setState({ sessions: [session()] }); + const { result } = setup({ start: [chip] }); + await expect(result.current.edits.confirmSend()).resolves.toBe(true); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('asks before an older version goes out, with going back as the default', async () => { + useEditSessions.setState({ sessions: [session({ changed: true })] }); + const { result } = setup({ start: [chip] }); + confirm.mockResolvedValueOnce(1); + await expect(result.current.edits.confirmSend()).resolves.toBe(false); + expect(confirm.mock.calls[0][0]).toMatchObject({ buttons: ['send_stale_send', 'send_stale_back'], defaultId: 1 }); + confirm.mockResolvedValueOnce(0); + await expect(result.current.edits.confirmSend()).resolves.toBe(true); + }); + + it('sees a save from a moment ago, which the list has not heard of', async () => { + useEditSessions.setState({ sessions: [session()] }); + const { result } = setup({ start: [chip] }); + bridge.settle.mockResolvedValueOnce([session({ changed: true })] as never); + confirm.mockResolvedValueOnce(1); + await expect(result.current.edits.confirmSend()).resolves.toBe(false); + expect(confirm).toHaveBeenCalledTimes(1); + }); + + it('a chip holding the version on disk has nothing to ask about, even before main hears it went up', async () => { + const outcome = await readEditedFile({ id: 's1', account: 'acct' }); + if (!outcome.ok) throw new Error('refused'); + useEditSessions.setState({ sessions: [session({ changed: true, hash: outcome.hash })] }); + const { result } = setup({ start: [{ file: outcome.file, name: 'a.docx', type: 'x/y', editSessionId: 's1', uploading: true }] }); + expect(result.current.edits.hasUpdate(result.current.attachments[0])).toBe(false); + await expect(result.current.edits.confirmSend()).resolves.toBe(true); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('sent or discarded: main decides, also on a flag this page has wrong', async () => { + // The page thinks nothing changed. Main has a save from a moment ago, and keeps it. + useEditSessions.setState({ sessions: [session({ id: 's1' }), session({ id: 's2', name: 'b.docx', changed: true })] }); + const { result } = setup({ start: [chip, { name: 'b.docx', type: 'x/y', blobId: 'c', editSessionId: 's2' }] }); + act(() => result.current.edits.finish()); + expect(bridge.release).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'file' })); + expect(bridge.release).toHaveBeenCalledWith('s2', JSON.stringify({ kind: 'file' })); + expect(bridge.claim).toHaveBeenCalledWith('s2', false); + expect(bridge.discard).not.toHaveBeenCalled(); + }); + + it('says whether a send has anything to ask about', () => { + const { result } = setup({ start: [chip] }); + expect(result.current.edits.holdsEdits()).toBe(true); + const { result: other } = setup({ start: [chipNamed('a.docx')] }); + expect(other.current.edits.holdsEdits()).toBe(false); + }); +}); + +describe('a forwarded part', () => { + it('from a message marked as phishing has no Edit', () => { + const { result } = setup({ start: [] }); + expect(result.current.edits.canEditChip({ ...chipNamed('a.docx'), editBlocked: true })).toBe(false); + }); + + it('from another login follows that login\'s rules, and its session is filed there', async () => { + const { result } = setup({ start: [{ ...chipNamed('a.docx'), sourceAccountId: 'other' }] }); + expect(result.current.edits.canEditChip(result.current.attachments[0])).toBe(true); + await act(() => result.current.edits.startEdit(result.current.attachments[0])); + expect(bridge.begin).toHaveBeenCalledWith('blob:other:b'); + const [, file] = bridge.open.mock.calls[0] as unknown as [string, { account: string }]; + expect(file.account).toBe('other'); + }); + + it('from a login whose organisation says no has no Edit, whatever this one says', () => { + const { result } = setup({ start: [] }); + forbidden.add('other'); + try { + expect(result.current.edits.canEditChip({ ...chipNamed('a.docx'), sourceAccountId: 'other' })).toBe(false); + expect(result.current.edits.canEditChip(chipNamed('a.docx'))).toBe(true); + } finally { + forbidden.clear(); + } + }); +}); + +describe('a reply from an offer', () => { + it('takes the waiting file, and the session comes with it', async () => { + const outcome = await readEditedFile({ id: 's1', account: 'acct' }); + if (!outcome.ok) throw new Error('refused'); + parkFilesForReply('e1', [outcome.file]); + const { result } = setup({ start: [], repliesTo: 'e1', draftId: null }); + await waitFor(() => expect(result.current.attachments.map((a) => a.name)).toEqual(['a.docx'])); + await waitFor(() => expect(result.current.attachments[0].editSessionId).toBe('s1')); + await waitFor(() => expect(bridge.taken).toHaveBeenCalledWith('s1', 'h2')); + expect(bridge.claim).toHaveBeenCalledWith('s1', true); + }); + + it('a version counts as taken only once it is up', async () => { + const outcome = await readEditedFile({ id: 's1', account: 'acct' }); + if (!outcome.ok) throw new Error('refused'); + parkFilesForReply('e1', [outcome.file]); + const { result } = setup({ start: [], repliesTo: 'e1', draftId: null, upload: () => ({ uploading: true }) }); + await waitFor(() => expect(result.current.attachments[0]?.editSessionId).toBe('s1')); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(bridge.taken).not.toHaveBeenCalled(); + act(() => result.current.setAttachments((list) => list.map((att) => ({ ...att, uploading: false, blobId: 'up' })))); + await waitFor(() => expect(bridge.taken).toHaveBeenCalledWith('s1', 'h2')); + }); + + it('a reply to another message leaves it waiting', async () => { + parkFilesForReply('e1', [new File(['x'], 'a.docx')]); + const { result } = setup({ start: [], repliesTo: 'e2', draftId: null }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(result.current.attachments).toEqual([]); + }); +}); + +describe('in a browser', () => { + it('nothing happens at all', () => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + const { result } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b' }] }); + expect(result.current.edits.canEditChip(chipNamed('a.docx'))).toBe(false); + expect(result.current.attachments[0].editSessionId).toBeUndefined(); + expect(() => result.current.edits.finish()).not.toThrow(); + }); +}); diff --git a/hooks/__tests__/use-wopi-status.test.ts b/hooks/__tests__/use-wopi-status.test.ts new file mode 100644 index 000000000..6e80b974f --- /dev/null +++ b/hooks/__tests__/use-wopi-status.test.ts @@ -0,0 +1,140 @@ +import { renderHook, waitFor, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; + +// /api/wopi/status needs the sign-in context cookie. The mail and files views +// can mount before it exists, so a 401 there says nothing about the editor and +// must not leave office editing off until the next page load (#1518). + +const ENABLED = { enabled: true, editExtensions: ['docx', 'odt'], viewExtensions: ['pdf'], newExtensions: ['docx'] }; + +const answer = (status: number, body: unknown = {}) => ({ ok: status >= 200 && status < 300, status, json: async () => body }); + +let fetchMock: Mock<(url: string, init?: RequestInit) => Promise>; +let useWopiStatus: typeof import('@/hooks/use-wopi-status').useWopiStatus; +let useAuthStore: typeof import('@/stores/auth-store').useAuthStore; + +const signIn = (client: object) => useAuthStore.setState({ isAuthenticated: true, client: client as never }); + +beforeEach(async () => { + fetchMock = vi.fn(); + // Only the status route counts: the auth store asks for other things on import. + vi.stubGlobal('fetch', (url: string, init?: RequestInit) => ( + url.includes('/api/wopi/status') ? fetchMock(url, init) : Promise.resolve(answer(404)) + )); + // The hook shares its answer through module state, like a page load does. + vi.resetModules(); + ({ useWopiStatus } = await import('@/hooks/use-wopi-status')); + ({ useAuthStore } = await import('@/stores/auth-store')); + useAuthStore.setState({ isAuthenticated: false, client: null }); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('useWopiStatus', () => { + it('asks nothing before sign-in, and asks once sign-in is done', async () => { + fetchMock.mockResolvedValue(answer(200, ENABLED)); + const { result } = renderHook(() => useWopiStatus(true)); + + await Promise.resolve(); + expect(fetchMock).not.toHaveBeenCalled(); + + signIn({}); + await waitFor(() => expect(result.current?.enabled).toBe(true)); + expect(result.current?.editExtensions).toContain('docx'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('asks nothing while the persisted flag says signed in but no client is connected yet', async () => { + fetchMock.mockResolvedValue(answer(200, ENABLED)); + useAuthStore.setState({ isAuthenticated: true, client: null }); + renderHook(() => useWopiStatus(true)); + + await Promise.resolve(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('turns editing on when a 401 is followed by the real client taking over', async () => { + // A local copy is on screen before the restore writes the context cookie. + fetchMock.mockResolvedValueOnce(answer(401)).mockResolvedValue(answer(200, ENABLED)); + signIn({ kind: 'local copy' }); + const { result } = renderHook(() => useWopiStatus(true)); + await waitFor(() => expect(result.current).not.toBeNull()); + expect(result.current?.enabled).toBe(false); + + signIn({ kind: 'real' }); + await waitFor(() => expect(result.current?.enabled).toBe(true)); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not share a 401 still in flight with the client that took over', async () => { + let finishFirst: (value: unknown) => void = () => {}; + fetchMock + .mockReturnValueOnce(new Promise((resolve) => { finishFirst = resolve; })) + .mockResolvedValue(answer(200, ENABLED)); + signIn({ kind: 'local copy' }); + const { result } = renderHook(() => useWopiStatus(true)); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + signIn({ kind: 'real' }); + await waitFor(() => expect(result.current?.enabled).toBe(true)); + finishFirst(answer(401)); + + // The late refusal does not take the answer back, for this view or the next. + await new Promise((resolve) => setTimeout(resolve)); + expect(result.current?.enabled).toBe(true); + const next = renderHook(() => useWopiStatus(true)); + await waitFor(() => expect(next.result.current?.enabled).toBe(true)); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it.each([401, 500, 502])('asks again on the next mount after a %i', async (status) => { + fetchMock.mockResolvedValueOnce(answer(status)).mockResolvedValue(answer(200, ENABLED)); + signIn({}); + const first = renderHook(() => useWopiStatus(true)); + await waitFor(() => expect(first.result.current).not.toBeNull()); + expect(first.result.current?.enabled).toBe(false); + + const second = renderHook(() => useWopiStatus(true)); + await waitFor(() => expect(second.result.current?.enabled).toBe(true)); + }); + + it('keeps a real answer for the page, across views and account switches', async () => { + fetchMock.mockResolvedValue(answer(200, ENABLED)); + signIn({ account: 'a' }); + const first = renderHook(() => useWopiStatus(true)); + await waitFor(() => expect(first.result.current?.enabled).toBe(true)); + + const second = renderHook(() => useWopiStatus(true)); + await waitFor(() => expect(second.result.current?.enabled).toBe(true)); + signIn({ account: 'b' }); + await waitFor(() => expect(useAuthStore.getState().client).toEqual({ account: 'b' })); + await new Promise((resolve) => setTimeout(resolve)); + + expect(first.result.current?.enabled).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('keeps a real "no editor" answer too, so mail views do not keep asking', async () => { + fetchMock.mockResolvedValue(answer(200, { enabled: false })); + signIn({}); + const first = renderHook(() => useWopiStatus(true)); + await waitFor(() => expect(first.result.current?.enabled).toBe(false)); + + const second = renderHook(() => useWopiStatus(true)); + await waitFor(() => expect(second.result.current?.enabled).toBe(false)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('asks nothing while the caller has it switched off', async () => { + fetchMock.mockResolvedValue(answer(200, ENABLED)); + signIn({}); + const { result } = renderHook(() => useWopiStatus(false)); + + await Promise.resolve(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.current).toBeNull(); + }); +}); diff --git a/hooks/use-composer-edits.ts b/hooks/use-composer-edits.ts new file mode 100644 index 000000000..6f7bb7367 --- /dev/null +++ b/hooks/use-composer-edits.ts @@ -0,0 +1,234 @@ +"use client"; + +import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react"; +import { useTranslations } from "next-intl"; +import { editAllowed, editFileInfo, followEditSessions, getEditBridge, parseEditSource, readEditedFile, takeFilesForReply, useEditSessions, type EditSource } from "@/lib/desktop/edit-attachments"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { useEditAttachment } from "@/hooks/use-edit-attachment"; +import { toast } from "@/stores/toast-store"; + +/** What this needs from a composer's attachment. */ +export interface EditableAttachment { + file?: File; + name: string; + type: string; + blobId?: string; + uploading?: boolean; + error?: boolean; + /** The Edit session this chip's file belongs to. Kept through every state change of the chip. */ + editSessionId?: string; + /** The login the part really belongs to, on a forward: its organisation's rules are the ones that count. */ + sourceAccountId?: string; + /** From a message marked as phishing: not opened in other programs here either. */ + editBlocked?: boolean; +} + +interface Options { + attachments: A[]; + setAttachments: Dispatch>; + addFiles: (files: File[]) => Promise; + load: (attachment: A) => Promise; + /** The local account the draft goes through. */ + account: string; + draftId: string | null; + ownerAccountId?: string; + /** The message a reply answers: a file waiting for that reply lands here. */ + repliesTo?: string; + /** Only once the composer can upload. */ + ready: boolean; +} + +/** + * A composer's part of Edit: its chips start sessions and hold them, so the offer to update + * sits on the chip rather than in the main window. A session goes when the draft is sent or + * discarded, unless it has edits nobody took: those stay, for the user to decide on. + */ +export function useComposerEdits({ attachments, setAttachments, addFiles, load, account, draftId, ownerAccountId, repliesTo, ready }: Options) { + const t = useTranslations("desktopEdit"); + const { canEdit, edit } = useEditAttachment(account); + const available = getEditBridge() !== null; + const sessions = useEditSessions((s) => s.sessions); + const instanceRef = useRef(`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`); + const heldRef = useRef(new Set()); + const takenRef = useRef(new WeakSet()); + const latest = useRef({ attachments, sessions }); + latest.current = { attachments, sessions }; + + useEffect(() => { + if (available) followEditSessions(); + }, [available]); + + // A file the user sent into a reply from an offer. + const repliedRef = useRef(false); + useEffect(() => { + if (!available || !ready || repliedRef.current || !repliesTo) return; + repliedRef.current = true; + const files = takeFilesForReply(repliesTo); + if (files.length > 0) void addFiles(files); + }, [available, ready, repliesTo, addFiles]); + + // A file read back from a session: its chip takes the session on, and once it's up the + // version it holds is no longer a change. + useEffect(() => { + if (!available) return; + const untagged = attachments.some((att) => !att.editSessionId && editFileInfo(att.file)); + if (untagged) { + setAttachments((list) => list.map((att) => { + const info = att.editSessionId ? undefined : editFileInfo(att.file); + return info ? { ...att, editSessionId: info.id } : att; + })); + } + for (const att of attachments) { + const info = editFileInfo(att.file); + if (!info || !att.file || !att.blobId || att.uploading || att.error || takenRef.current.has(att.file)) continue; + takenRef.current.add(att.file); + void getEditBridge()?.taken(info.id, info.hash); + } + }, [available, attachments, setAttachments]); + + // A draft opened again finds its sessions by the draft's id and the file's name. + useEffect(() => { + if (!available || !draftId) return; + const mine = sessions.filter((session) => { + const source = parseEditSource(session.source); + return source.kind === "draft" && source.draftId === draftId && !session.claimed; + }); + if (mine.length === 0) return; + setAttachments((list) => { + let changed = false; + const next = list.map((att) => { + if (att.editSessionId) return att; + const match = mine.find((session) => session.name === att.name && !list.some((other) => other.editSessionId === session.id)); + if (!match) return att; + changed = true; + return { ...att, editSessionId: match.id }; + }); + return changed ? next : list; + }); + }, [available, draftId, sessions, setAttachments]); + + // Holds what's on a chip. One taken off a chip goes unless it has edits, which stay in the offers. + const heldIds = attachments.flatMap((att) => (att.editSessionId ? [att.editSessionId] : [])); + const heldKey = [...new Set(heldIds)].sort().join("\n"); + useEffect(() => { + const bridge = getEditBridge(); + if (!bridge) return; + const now = new Set(heldKey ? heldKey.split("\n") : []); + for (const id of now) { + if (!heldRef.current.has(id)) bridge.claim(id, true); + } + for (const id of heldRef.current) { + if (now.has(id)) continue; + bridge.claim(id, false); + void bridge.release(id, JSON.stringify({ kind: "file" } satisfies EditSource)); + } + heldRef.current = now; + }, [heldKey]); + + // Follows the draft's id, which changes with every save. + useEffect(() => { + const bridge = getEditBridge(); + if (!bridge || !heldKey) return; + const source = JSON.stringify({ kind: "draft", draftId, ownerAccountId } satisfies EditSource); + for (const id of heldKey.split("\n")) bridge.setSource(id, source); + }, [heldKey, draftId, ownerAccountId]); + + // Closing the composer isn't an end: the draft may be opened again. + useEffect(() => () => { + const bridge = getEditBridge(); + for (const id of heldRef.current) bridge?.claim(id, false); + heldRef.current = new Set(); + }, []); + + const sessionOf = useCallback((att: A) => (att.editSessionId ? sessions.find((s) => s.id === att.editSessionId) : undefined), [sessions]); + + /** Whether this chip has saved edits to take. Its own file on its way up isn't one. */ + const hasUpdate = useCallback((att: A) => { + const session = sessionOf(att); + return session?.changed === true && editFileInfo(att.file)?.hash !== session.hash; + }, [sessionOf]); + + /** The part's own account decides too, on a forward from another login. */ + const canEditChip = useCallback((att: A) => ( + !att.editBlocked && canEdit(att.name) && (!att.sourceAccountId || editAllowed(att.sourceAccountId)) + ), [canEdit]); + + const startEdit = useCallback(async (att: A) => { + const owner = att.sourceAccountId ?? account; + const key = att.editSessionId + ? `session:${att.editSessionId}` + : att.blobId ? `blob:${owner}:${att.blobId}` : `composer:${instanceRef.current}:${att.name}`; + const result = await edit({ + key, + name: att.name, + type: att.type, + account: owner, + source: { kind: "draft", draftId, ownerAccountId }, + load: () => load(att), + }); + if (result.ok) { + setAttachments((list) => list.map((item) => ( + (att.file ? item.file === att.file : item.blobId === att.blobId) ? { ...item, editSessionId: result.id } : item + ))); + } + }, [account, draftId, edit, load, ownerAccountId, setAttachments]); + + /** The edited file goes up first. Only then does the old one come off, so a failed upload loses nothing. */ + const update = useCallback(async (att: A) => { + const id = att.editSessionId; + if (!id) return; + const outcome = await readEditedFile({ id, account }); + if (!outcome.ok) { + toast.error(t(outcome.reason === "busy" ? "read_busy" : outcome.reason === "policy" ? "blocked_policy" : "read_failed", { name: att.name })); + return; + } + const { file } = outcome; + await addFiles([file]); + setAttachments((list) => { + const added = list.find((item) => item.file === file); + if (added?.blobId && !added.error) return list.filter((item) => item.file === file || item.editSessionId !== id); + return list.filter((item) => item.file !== file); + }); + }, [account, addFiles, setAttachments, t]); + + /** Whether a send has anything to ask about. */ + const holdsEdits = useCallback(() => latest.current.attachments.some((att) => att.editSessionId), []); + + /** Edits on the way out in an older version: asks first. False: don't send. */ + const confirmSend = useCallback(async (): Promise => { + const { attachments: list } = latest.current; + // Someone who saves and goes straight to Send is the case this is for: the watcher may not have seen it yet. + const all = await getEditBridge()?.settle() ?? latest.current.sessions; + const stale = list.flatMap((att) => { + const session = att.editSessionId ? all.find((s) => s.id === att.editSessionId) : undefined; + return session?.changed && editFileInfo(att.file)?.hash !== session.hash ? [session] : []; + }); + const dialogs = getDesktopBridge()?.dialogs; + if (stale.length === 0 || !dialogs) return true; + const choice = await dialogs.confirm({ + type: "warning", + message: t("send_stale_title", { name: stale[0].name }), + detail: t("send_stale_detail"), + buttons: [t("send_stale_send"), t("send_stale_back")], + defaultId: 1, + }); + return choice === 0; + }, [t]); + + /** + * The draft was sent or discarded. Main decides what goes, as only it knows about a save from a + * moment ago. Reads no state of the component: this can run after it has gone. + */ + const finish = useCallback(() => { + const bridge = getEditBridge(); + if (!bridge) return; + const ids = new Set(latest.current.attachments.flatMap((att) => (att.editSessionId ? [att.editSessionId] : []))); + for (const id of ids) { + heldRef.current.delete(id); + bridge.claim(id, false); + void bridge.release(id, JSON.stringify({ kind: "file" } satisfies EditSource)); + } + }, []); + + return { canEditChip, startEdit, hasUpdate, update, holdsEdits, confirmSend, finish }; +} diff --git a/hooks/use-config.ts b/hooks/use-config.ts index d54a208d7..f41950315 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -40,6 +40,7 @@ export interface ConfigData { loginShowSubtitle: boolean; loginShowTotp: boolean; loginShowTokenLogin: boolean; + desktopBrowserSignIn: boolean; loginShowRememberMe: boolean; loginShowVersion: boolean; demoMode: boolean; @@ -199,6 +200,7 @@ export function useConfig(): AppConfig { loginShowSubtitle: configCache?.loginShowSubtitle ?? true, loginShowTotp: configCache?.loginShowTotp ?? true, loginShowTokenLogin: configCache?.loginShowTokenLogin ?? false, + desktopBrowserSignIn: configCache?.desktopBrowserSignIn ?? true, loginShowRememberMe: configCache?.loginShowRememberMe ?? false, loginShowVersion: configCache?.loginShowVersion ?? true, demoMode: configCache?.demoMode || false, @@ -252,6 +254,7 @@ export function useConfig(): AppConfig { loginShowSubtitle: configCache.loginShowSubtitle, loginShowTotp: configCache.loginShowTotp, loginShowTokenLogin: configCache.loginShowTokenLogin, + desktopBrowserSignIn: configCache.desktopBrowserSignIn, loginShowRememberMe: configCache.loginShowRememberMe, loginShowVersion: configCache.loginShowVersion, demoMode: configCache.demoMode, @@ -306,6 +309,7 @@ export function useConfig(): AppConfig { loginShowSubtitle: data.loginShowSubtitle, loginShowTotp: data.loginShowTotp, loginShowTokenLogin: data.loginShowTokenLogin, + desktopBrowserSignIn: data.desktopBrowserSignIn, loginShowRememberMe: data.loginShowRememberMe, loginShowVersion: data.loginShowVersion, demoMode: data.demoMode, diff --git a/hooks/use-edit-attachment.ts b/hooks/use-edit-attachment.ts new file mode 100644 index 000000000..012b2eb9f --- /dev/null +++ b/hooks/use-edit-attachment.ts @@ -0,0 +1,51 @@ +"use client"; + +import { useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { canEditFile, editAllowed, startAttachmentEdit, type EditStartResult, type StartEditInput } from "@/lib/desktop/edit-attachments"; +import { deviceSubjectOf } from "@/lib/device-policy"; +import { useDevicePolicy } from "@/hooks/use-device-policy"; +import { useAuthStore } from "@/stores/auth-store"; +import { toast } from "@/stores/toast-store"; + +const FAILURE_KEYS: Partial["reason"], string>> = { + policy: "blocked_policy", + "no-app": "no_app", + type: "blocked_type", + "too-large": "too_large", + full: "too_many", +}; + +/** The local account a message or a draft belongs to, where a copy of its file is filed. */ +export function editAccountOf(email: { sourceClientAccountId?: string | null } | null | undefined): string { + return deviceSubjectOf(email) ?? useAuthStore.getState().activeAccountId ?? ""; +} + +/** + * Edit, for whatever shows an attachment: whether to offer it, and starting it with the app's + * words for how it went. `account` is the local account the attachment belongs to; the + * hook follows that account's policy, so the item goes as soon as the answer turns to no. + */ +export function useEditAttachment(account: string) { + const t = useTranslations("desktopEdit"); + const devices = useDevicePolicy(account || null); + const offered = !devices.noDownloads && !devices.noExport; + const canEdit = useCallback((name: string | null | undefined) => ( + offered && !!account && canEditFile(name) && editAllowed(account) + ), [offered, account]); + const edit = useCallback(async (input: StartEditInput): Promise => { + let result: EditStartResult; + try { + result = await startAttachmentEdit(input); + } catch (error) { + console.error("Could not open an attachment for editing:", error); + result = { ok: false, reason: "failed" }; + } + if (result.ok) toast.success(t("opened", { name: input.name })); + // Locked: the lock screen is what's saying it. + // Ticket: a newer click took over, and that one speaks. + else if (result.reason !== "locked" && result.reason !== "ticket") toast.error(t(FAILURE_KEYS[result.reason] ?? "failed")); + return result; + }, [t]); + return { canEdit, edit }; +} diff --git a/hooks/use-phishing-report.ts b/hooks/use-phishing-report.ts index 4cdd5801f..9c9a5485c 100644 --- a/hooks/use-phishing-report.ts +++ b/hooks/use-phishing-report.ts @@ -66,6 +66,11 @@ export function usePhishingReport(emails: ReadonlyArray (email ? s.reported[emailKeyFor(email)] === true : false)); return isPhishingMarked(email) || reported; diff --git a/hooks/use-wopi-status.ts b/hooks/use-wopi-status.ts index 4d6dd014a..c3b575edf 100644 --- a/hooks/use-wopi-status.ts +++ b/hooks/use-wopi-status.ts @@ -3,11 +3,12 @@ import { useEffect, useState } from "react"; import { IS_LITE } from "@/lib/lite"; import { apiFetch } from "@/lib/browser-navigation"; +import { useAuthStore } from "@/stores/auth-store"; /** * Whether a WOPI document editor is configured for this deployment and which - * file extensions it handles (#425). Fetched once per page load and shared - * across consumers via a module-level promise. + * file extensions it handles (#425). The answer is the same for every account, + * so the first real one is kept for the page load and shared across consumers. */ export interface WopiStatus { enabled: boolean; @@ -19,41 +20,61 @@ export interface WopiStatus { const DISABLED: WopiStatus = { enabled: false, editExtensions: [], viewExtensions: [], newExtensions: [] }; -let statusPromise: Promise | null = null; +let known: WopiStatus | null = null; +let pending: Promise | null = null; +let pendingClient: unknown = null; -function fetchWopiStatus(): Promise { +function fetchWopiStatus(client: unknown): Promise { // The WOPI bridge is a server feature; the static build never has an editor. if (IS_LITE) return Promise.resolve(DISABLED); - if (!statusPromise) { - statusPromise = apiFetch("/api/wopi/status") - .then((res) => (res.ok ? res.json() : DISABLED)) - .then((data) => ({ + if (known) return Promise.resolve(known); + // A request sent for an earlier client may be refused for lack of the sign-in + // context this one brought, so only the same client shares it. + if (pending && pendingClient === client) return pending; + + const request: Promise = apiFetch("/api/wopi/status") + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((data) => { + known = { enabled: !!data?.enabled, editExtensions: Array.isArray(data?.editExtensions) ? data.editExtensions : [], viewExtensions: Array.isArray(data?.viewExtensions) ? data.viewExtensions : [], newExtensions: Array.isArray(data?.newExtensions) ? data.newExtensions : [], - })) - .catch(() => { - statusPromise = null; // allow a retry on the next mount - return DISABLED; - }); - } - return statusPromise; + }; + return known; + }) + // A refusal or an outage says nothing about the editor (#1518): show no + // editor for now and ask again on the next mount or client. + .catch(() => DISABLED) + .finally(() => { + if (pending === request) pending = null; + }); + pending = request; + pendingClient = client; + return request; } export function useWopiStatus(enabled: boolean): WopiStatus | null { const [status, setStatus] = useState(null); + // The route needs the sign-in context cookie, which is written before the + // store holds a connected client. `isAuthenticated` alone comes back from + // storage on load, and a local copy's client gives way to the real one once + // the restore wrote the cookie, so this follows the client itself (#1518). + const client = useAuthStore((state) => (state.isAuthenticated ? state.client : null)); useEffect(() => { - if (!enabled) return; + if (!enabled || !client) return; let cancelled = false; - fetchWopiStatus().then((s) => { + fetchWopiStatus(client).then((s) => { if (!cancelled) setStatus(s); }); return () => { cancelled = true; }; - }, [enabled]); + }, [enabled, client]); return status; } diff --git a/lib/__tests__/desktop-handoff-routes.test.ts b/lib/__tests__/desktop-handoff-routes.test.ts new file mode 100644 index 000000000..be1bfa9ba --- /dev/null +++ b/lib/__tests__/desktop-handoff-routes.test.ts @@ -0,0 +1,469 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createHash } from 'crypto'; +import { NextRequest } from 'next/server'; + +/** + * The desktop app's sign-in on an instance: what the browser checks and parks, and what the + * app's own page gets for the code. The real store, budgets and routes, with the mail server faked. + */ + +const cookieJar = vi.hoisted(() => new Map }>()); +vi.mock('next/headers', () => ({ + cookies: async () => ({ + get: (name: string) => (cookieJar.has(name) ? { name, value: cookieJar.get(name)!.value } : undefined), + set: (name: string, value: string, options?: Record) => { + cookieJar.set(name, { value, options }); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + getAll: () => [...cookieJar.entries()].map(([name, { value }]) => ({ name, value })), + }), +})); + +const config = vi.hoisted(() => ({ values: {} as Record })); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: (key: string, fallback: unknown) => (key in config.values ? config.values[key] : fallback), + }, +})); + +const verifyJmapAuth = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/auth/verify-jmap-auth', async (original) => ({ + ...(await original()), + verifyJmapAuth, +})); + +const stalwartPasswordLogin = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/auth/stalwart-password-login', async (original) => ({ + ...(await original()), + stalwartPasswordLogin, +})); + +const reserveLoginAttempt = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/auth/verify-budget', async (original) => ({ + ...(await original()), + reserveLoginAttempt, +})); + +const discoverOAuth = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/oauth/discovery', () => ({ discoverOAuth })); + +vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } })); + +const { POST: check } = await import('@/app/api/auth/desktop-handoff/check/route'); +const { POST: park } = await import('@/app/api/auth/desktop-handoff/park/route'); +const { POST: redeem } = await import('@/app/api/auth/desktop-handoff/redeem/route'); +const { POST: discard } = await import('@/app/api/auth/desktop-handoff/discard/route'); +const { POST: mobileRedeem } = await import('@/app/api/auth/handoff/redeem/route'); +const { JmapAuthVerificationError } = await import('@/lib/auth/verify-jmap-auth'); +const { HANDOFF_CONFIRM_TTL_MS, HANDOFF_TTL_MS, resetHandoffStoreForTests, stashHandoff } = await import('@/lib/auth/handoff-store'); +const { desktopHandoffDeclineUrl, desktopHandoffPort, desktopHandoffTarget, mobileHandoffTarget } = await import('@/lib/auth/mobile-handoff'); +const { isDesktopBlockedRoute } = await import('@/lib/desktop/blocked-routes'); + +const ORIGIN = 'https://bulwark.example'; +const VERIFIER = 'v'.repeat(64); +const CHALLENGE = createHash('sha256').update(VERIFIER, 'ascii').digest('base64url'); +const SERVER = 'https://mail.example'; +const TOKENS = { ok: true, tokens: { access_token: 'access', refresh_token: 'refresh', expires_in: 3600 }, tokenEndpoint: `${SERVER}/auth/token`, clientId: 'x' }; + +/** A client of its own per test, so the budgets don't carry over: the real ones, unmocked. */ +let clientAddress = ''; +let nextClient = 1; + +function post(path: string, body: unknown, origin = ORIGIN) { + return new NextRequest(`${ORIGIN}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin, Host: 'bulwark.example', 'x-forwarded-for': clientAddress }, + body: JSON.stringify(body), + }); +} + +const parkBody = (extra: Record = {}) => ({ + code_challenge: CHALLENGE, + state: 'state-1', + server_url: SERVER, + username: 'a@example.org', + password: 'secret', + ...extra, +}); +const checkBody = (extra: Record = {}) => ({ server_url: SERVER, username: 'a@example.org', password: 'secret', ...extra }); + +const parkIt = (extra: Record = {}) => park(post('/api/auth/desktop-handoff/park', parkBody(extra))); + +async function parkedCode(extra: Record = {}): Promise { + const answer = await parkIt(extra); + expect(answer.status).toBe(200); + return ((await answer.json()) as { code: string }).code; +} + +const redeemBody = (code: string, extra: Record = {}) => ({ code, code_verifier: VERIFIER, slot: 1, remember: true, allowed_servers: null, custom_servers_allowed: true, ...extra }); +const redeemIt = (code: string, extra: Record = {}) => redeem(post('/api/auth/desktop-handoff/redeem', redeemBody(code, extra))); + +/** Everything the store holds, in the open. Nothing secret may be in it after its use. */ +const heldInMemory = () => JSON.stringify([...((globalThis as unknown as { __bulwarkMobileHandoffStore?: Map }).__bulwarkMobileHandoffStore ?? new Map()).values()]); + +let revoked: string[]; + +beforeEach(() => { + cookieJar.clear(); + resetHandoffStoreForTests(); + clientAddress = `203.0.113.${(nextClient += 1)}`; + config.values = { jmapServerUrl: SERVER }; + verifyJmapAuth.mockReset(); + verifyJmapAuth.mockResolvedValue(undefined); + stalwartPasswordLogin.mockReset(); + stalwartPasswordLogin.mockResolvedValue(TOKENS); + reserveLoginAttempt.mockReset(); + reserveLoginAttempt.mockResolvedValue(async () => {}); + discoverOAuth.mockReset(); + discoverOAuth.mockResolvedValue({ revocation_endpoint: `${SERVER}/revoke` }); + revoked = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).endsWith('/revoke')) revoked.push(new URLSearchParams(String(init?.body)).get('token') ?? ''); + return new Response('{}', { status: 200 }); + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('the target', () => { + it('is only ever this computer on the one path', () => { + expect(desktopHandoffTarget('http://127.0.0.1:51234/bulwark-desktop/callback')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback'); + for (const bad of [ + 'http://127.0.0.1/bulwark-desktop/callback', + 'http://127.0.0.1:99999/bulwark-desktop/callback', + 'http://localhost:51234/bulwark-desktop/callback', + 'https://127.0.0.1:51234/bulwark-desktop/callback', + 'http://127.0.0.1:51234/other', + 'http://127.0.0.1:51234/bulwark-desktop/callback?x=1', + 'http://127.0.0.1@evil.example:51234/bulwark-desktop/callback', + 'http://evil.example/bulwark-desktop/callback', + 'bulwarkmobile://auth/callback', + null, + ]) { + expect(desktopHandoffTarget(bad), String(bad)).toBe(''); + } + }); + + it('leaves the phone\'s rule as it was', () => { + expect(mobileHandoffTarget('http://127.0.0.1:51234/bulwark-desktop/callback')).toBe(''); + }); + + it('a no goes back with the state and no code', () => { + const target = 'http://127.0.0.1:51234/bulwark-desktop/callback'; + expect(desktopHandoffPort(target)).toBe('51234'); + expect(desktopHandoffDeclineUrl(target, 's t')).toBe(`${target}?error=access_denied&state=s+t`); + }); + + it('the embedded build has none of these routes', () => { + for (const route of ['check', 'park', 'redeem', 'discard']) expect(isDesktopBlockedRoute(`/api/auth/desktop-handoff/${route}`)).toBe(true); + expect(isDesktopBlockedRoute('/api/auth/desktop-oauth')).toBe(false); + }); +}); + +describe('checking, before the question', () => { + const checkIt = (extra: Record = {}, origin = ORIGIN) => check(post('/api/auth/desktop-handoff/check', checkBody(extra), origin)); + + it('keeps nothing: no record, no cookie, and the password appears nowhere', async () => { + const answer = await checkIt(); + expect(answer.status).toBe(200); + expect(heldInMemory()).toBe('[]'); + expect([...cookieJar.keys()]).toEqual([]); + expect(stalwartPasswordLogin).not.toHaveBeenCalled(); + }); + + it('refuses another origin', async () => { + expect((await checkIt({}, 'https://evil.example')).status).toBe(403); + expect(verifyJmapAuth).not.toHaveBeenCalled(); + }); + + it('says a wrong password is wrong, and that a second factor is needed', async () => { + verifyJmapAuth.mockRejectedValueOnce(new JmapAuthVerificationError('no', 401, 401)); + expect(await (await checkIt()).json()).toEqual({ error: 'invalid_credentials' }); + verifyJmapAuth.mockRejectedValueOnce(new JmapAuthVerificationError('no', 401, 402)); + expect(await (await checkIt()).json()).toEqual({ error: 'totp_required' }); + }); + + it('holds no password on an OAuth-only instance', async () => { + config.values = { jmapServerUrl: SERVER, oauthEnabled: true, oauthOnly: true }; + expect((await checkIt()).status).toBe(403); + expect(verifyJmapAuth).not.toHaveBeenCalled(); + }); + + it('over the login budget, nothing reaches the mail server, and the answer says why', async () => { + reserveLoginAttempt.mockResolvedValue(null); + const answer = await checkIt(); + expect(answer.status).toBe(429); + expect(await answer.json()).toEqual({ error: 'too_many_attempts' }); + expect(verifyJmapAuth).not.toHaveBeenCalled(); + }); + + it('never goes to a server the instance does not know', async () => { + expect((await checkIt({ server_url: 'https://evil.example' })).status).toBe(400); + expect(verifyJmapAuth).not.toHaveBeenCalled(); + }); +}); + +describe('the instance admin\'s switch', () => { + it('closes every route, with nothing checked, parked or redeemed', async () => { + const code = await parkedCode(); + verifyJmapAuth.mockClear(); + stalwartPasswordLogin.mockClear(); + config.values = { jmapServerUrl: SERVER, desktopBrowserSignIn: false }; + expect((await check(post('/api/auth/desktop-handoff/check', checkBody()))).status).toBe(403); + expect((await parkIt()).status).toBe(403); + expect(verifyJmapAuth).not.toHaveBeenCalled(); + expect(stalwartPasswordLogin).not.toHaveBeenCalled(); + expect((await redeemIt(code)).status).toBe(403); + }); +}); + +describe('parking, after the yes', () => { + it('hands over tokens wherever the server can give them, and leaves this browser with no session', async () => { + const code = await parkedCode(); + expect(verifyJmapAuth).not.toHaveBeenCalled(); + expect([...cookieJar.keys()]).toEqual([]); + expect(heldInMemory()).not.toContain('secret'); + const answer = await redeemIt(code); + const body = await answer.json(); + expect(body).toMatchObject({ flow: 'tokens', password_login: true, username: 'a@example.org', access_token: 'access' }); + expect(JSON.stringify(body)).not.toContain('secret'); + expect(JSON.stringify(body)).not.toContain('"refresh"'); + expect(cookieJar.get('jmap_rt_1')?.value).toBe('refresh'); + }); + + it.each(['login_endpoint_missing', 'login_rejected', 'login_unreachable', 'token_exchange_failed'])('a password only as the last resort: %s', async (error) => { + stalwartPasswordLogin.mockResolvedValue({ ok: false, error }); + const code = await parkedCode(); + expect(verifyJmapAuth).toHaveBeenCalledOnce(); + expect(await (await redeemIt(code)).json()).toEqual({ flow: 'password', server_url: SERVER, username: 'a@example.org', password: 'secret' }); + }); + + it('a password that turns out wrong, on the way, parks nothing', async () => { + stalwartPasswordLogin.mockResolvedValue({ ok: false, error: 'invalid_credentials' }); + const answer = await parkIt(); + expect(answer.status).toBe(401); + expect(await answer.json()).toEqual({ error: 'invalid_credentials' }); + expect(heldInMemory()).toBe('[]'); + }); + + it('a wrong two-factor code gets no fallback to a password', async () => { + stalwartPasswordLogin.mockResolvedValue({ ok: false, error: 'login_rejected' }); + expect((await parkIt({ totp: '000000' })).status).toBe(502); + expect(verifyJmapAuth).not.toHaveBeenCalled(); + expect(heldInMemory()).toBe('[]'); + }); + + it('refuses another origin', async () => { + expect((await park(post('/api/auth/desktop-handoff/park', parkBody(), 'https://evil.example'))).status).toBe(403); + expect(stalwartPasswordLogin).not.toHaveBeenCalled(); + }); + + it.each([ + ['no challenge', { code_challenge: '' }], + ['a bad challenge', { code_challenge: 'short' }], + ['no state', { state: '' }], + ['no password', { password: '' }], + ])('refuses %s', async (_name, extra) => { + expect((await parkIt(extra)).status).toBe(400); + expect(stalwartPasswordLogin).not.toHaveBeenCalled(); + }); + + it('what was checked is what is parked: the body can\'t name another server for the app', async () => { + config.values = { jmapServerUrl: SERVER, jmapServers: [{ id: 'two', url: 'https://two.example', name: 'Two' }] }; + const code = await parkedCode({ server_id: 'two', server_url: 'https://evil.example' }); + expect(stalwartPasswordLogin.mock.calls[0][0]).toMatchObject({ serverUrl: 'https://two.example' }); + expect(await (await redeemIt(code)).json()).toMatchObject({ server_url: 'https://two.example' }); + }); +}); + +describe('the budget, with one address for a whole office', () => { + it('sign-ins that work never run out', async () => { + clientAddress = '198.51.100.7'; + for (let i = 0; i < 25; i += 1) expect((await parkIt()).status, `sign-in ${i}`).toBe(200); + }); + + it('after ten failures it refuses before going upstream, so no code is spent, and says why', async () => { + clientAddress = '198.51.100.8'; + stalwartPasswordLogin.mockResolvedValue({ ok: false, error: 'invalid_credentials' }); + for (let i = 0; i < 10; i += 1) expect((await parkIt({ totp: '000000' })).status).toBe(401); + stalwartPasswordLogin.mockClear(); + const answer = await parkIt({ totp: '123456' }); + expect(answer.status).toBe(429); + expect(await answer.json()).toEqual({ error: 'too_many_attempts' }); + expect(answer.headers.get('Retry-After')).toBeTruthy(); + expect(stalwartPasswordLogin).not.toHaveBeenCalled(); + }); + + it('a bad code to redeem is what its budget counts, and a good one gives it back', async () => { + clientAddress = '198.51.100.9'; + for (let i = 0; i < 12; i += 1) expect((await redeemIt(await parkedCode())).status).toBe(200); + for (let i = 0; i < 10; i += 1) expect((await redeemIt('f'.repeat(64))).status).toBe(400); + expect((await redeemIt('f'.repeat(64))).status).toBe(429); + }); +}); + +describe('redeeming, in the app', () => { + it('a code can be used once', async () => { + const code = await parkedCode(); + expect((await redeemIt(code)).status).toBe(200); + const again = await redeemIt(code); + expect(again.status).toBe(400); + expect(await again.json()).toEqual({ error: 'invalid_code' }); + }); + + it('a wrong verifier gets nothing, and spends the code', async () => { + const code = await parkedCode(); + const wrong = await redeemIt(code, { code_verifier: 'w'.repeat(64) }); + expect(wrong.status).toBe(400); + expect(await wrong.json()).toEqual({ error: 'invalid_verifier' }); + expect((await redeemIt(code)).status).toBe(400); + expect([...cookieJar.keys()]).toEqual([]); + }); + + it('a late code is told so', async () => { + const code = await parkedCode(); + const now = Date.now(); + vi.spyOn(Date, 'now').mockReturnValue(now + 61_000); + const answer = await redeemIt(code); + expect(answer.status).toBe(400); + expect(await answer.json()).toEqual({ error: 'expired_code' }); + }); + + it('keeps the instance\'s strict cookies strict', async () => { + config.values = { jmapServerUrl: SERVER, cookieSameSite: 'strict' }; + await redeemIt(await parkedCode()); + expect(cookieJar.get('jmap_rt_1')?.options).toMatchObject({ sameSite: 'strict', httpOnly: true }); + }); + + it('a server off the device\'s list gets nothing written, the token is revoked, and the code is gone', async () => { + const code = await parkedCode(); + const answer = await redeemIt(code, { allowed_servers: ['https://other.example'] }); + expect(answer.status).toBe(403); + expect(await answer.json()).toMatchObject({ code: 'blocked_by_policy' }); + expect([...cookieJar.keys()]).toEqual([]); + expect(revoked).toEqual(['refresh']); + expect((await redeemIt(code)).status).toBe(400); + }); + + it('a list that names the server lets it through', async () => { + expect((await redeemIt(await parkedCode(), { allowed_servers: [SERVER] })).status).toBe(200); + }); + + describe('a server of the user\'s own, on an instance that allows them', () => { + const OWN = 'https://1.1.1.1'; + beforeEach(() => { + config.values = { jmapServerUrl: SERVER, allowCustomJmapEndpoint: true }; + }); + + it('is refused when the device\'s rule says no: forced InstanceUrl, or AllowStandalone off', async () => { + const code = await parkedCode({ server_url: OWN }); + const answer = await redeemIt(code, { custom_servers_allowed: false }); + expect(answer.status).toBe(403); + expect(await answer.json()).toMatchObject({ code: 'blocked_by_policy' }); + expect([...cookieJar.keys()]).toEqual([]); + }); + + it('is refused when a page says nothing about it', async () => { + const code = await parkedCode({ server_url: OWN }); + const { custom_servers_allowed: _left, ...withoutRule } = redeemBody(code); + expect((await redeem(post('/api/auth/desktop-handoff/redeem', withoutRule))).status).toBe(403); + }); + + it('goes through when the device allows it', async () => { + expect((await redeemIt(await parkedCode({ server_url: OWN }))).status).toBe(200); + }); + + it('the instance\'s own server is no such thing', async () => { + expect((await redeemIt(await parkedCode(), { custom_servers_allowed: false })).status).toBe(200); + }); + }); + + it('refuses another origin', async () => { + const code = await parkedCode(); + expect((await redeem(post('/api/auth/desktop-handoff/redeem', redeemBody(code), 'https://evil.example'))).status).toBe(403); + }); +}); + +describe('what stays behind', () => { + const ssoBundle = { + flow: 'desktop-tokens' as const, + server_url: SERVER, + server_id: null, + custom: false, + access_token: 'sso-access', + refresh_token: 'sso-refresh', + expires_in: 3600, + signed_in_at: 1, + }; + const parkedSso = () => stashHandoff(ssoBundle, { challenge: CHALLENGE, state: 's', awaitConfirm: true }); + const discardIt = (code: string) => discard(post('/api/auth/desktop-handoff/discard', { code })); + + it('"Not now" takes the tokens out of the store and revokes the refresh token', async () => { + const code = await parkedSso(); + expect(heldInMemory()).toContain('sso-refresh'); + expect((await discardIt(code)).status).toBe(200); + expect(heldInMemory()).not.toContain('sso-refresh'); + expect(heldInMemory()).not.toContain('sso-access'); + expect(revoked).toEqual(['sso-refresh']); + expect((await redeemIt(code)).status).toBe(400); + }); + + it('a provider with no revocation endpoint: the store is still emptied', async () => { + discoverOAuth.mockResolvedValue({}); + const code = await parkedSso(); + expect((await discardIt(code)).status).toBe(200); + expect(heldInMemory()).not.toContain('sso-refresh'); + expect(revoked).toEqual([]); + }); + + it('discards only the desktop\'s, and nothing twice', async () => { + const phone = await stashHandoff({ flow: 'password', server_url: SERVER, username: 'a', password: 'phone-secret' }, { challenge: CHALLENGE, state: 's' }); + await discardIt(phone); + expect(heldInMemory()).toContain('phone-secret'); + const code = await parkedSso(); + await discardIt(code); + await discardIt(code); + expect(revoked).toEqual(['sso-refresh']); + }); + + it('a code no one knows gets nothing', async () => { + expect((await discardIt('nonsense')).status).toBe(400); + }); + + it('with nobody coming back, a timer wipes what was parked, at its real expiry', async () => { + vi.useFakeTimers(); + await stashHandoff({ flow: 'desktop-password', server_url: SERVER, server_id: null, custom: false, username: 'a', password: 'timer-secret' }, { challenge: CHALLENGE, state: 's' }); + await parkedSso(); + expect(heldInMemory()).toContain('timer-secret'); + vi.advanceTimersByTime(HANDOFF_TTL_MS + 2); + expect(heldInMemory()).not.toContain('timer-secret'); + expect(heldInMemory()).toContain('sso-refresh'); + vi.advanceTimersByTime(HANDOFF_CONFIRM_TTL_MS); + expect(heldInMemory()).not.toContain('sso-refresh'); + }); +}); + +describe('the two kinds of code stay apart', () => { + it('the phone\'s redeem gives away nothing of a desktop sign-in', async () => { + const code = await parkedCode(); + const answer = await mobileRedeem(post('/api/auth/handoff/redeem', { code, code_verifier: VERIFIER })); + expect(answer.status).toBe(400); + const body = JSON.stringify(await answer.json()); + expect(body).not.toContain('refresh'); + expect(body).not.toContain('access'); + }); + + it('the desktop redeem takes no phone code', async () => { + const code = await stashHandoff({ flow: 'password', server_url: SERVER, username: 'a', password: 'secret' }, { challenge: CHALLENGE, state: 's' }); + const answer = await redeemIt(code); + expect(answer.status).toBe(400); + expect(JSON.stringify(await answer.json())).not.toContain('secret'); + }); +}); diff --git a/lib/__tests__/fixtures/config.ts b/lib/__tests__/fixtures/config.ts index 06d12ddee..02fd8567c 100644 --- a/lib/__tests__/fixtures/config.ts +++ b/lib/__tests__/fixtures/config.ts @@ -32,6 +32,7 @@ export function createConfig(overrides: Partial = {}): ConfigData { loginShowSubtitle: true, loginShowTotp: true, loginShowTokenLogin: false, + desktopBrowserSignIn: true, loginShowRememberMe: false, loginShowVersion: true, demoMode: false, diff --git a/lib/__tests__/helpers/push-click.ts b/lib/__tests__/helpers/push-click.ts index 369efd6a2..eb2f8e2c0 100644 --- a/lib/__tests__/helpers/push-click.ts +++ b/lib/__tests__/helpers/push-click.ts @@ -5,7 +5,7 @@ import vm from 'node:vm'; export async function pushClick( accountId: string, preview: (url: string) => Promise, - options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string; noParamsSize?: boolean } = {}, + options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string; noParamsSize?: boolean; onScreen?: string[] } = {}, ): Promise { const source = readFileSync(resolve('public/sw.js'), 'utf8'); let notification: { data: unknown } | undefined; @@ -33,7 +33,7 @@ export async function pushClick( }, registration: { showNotification: async (_title: string, data: { data: unknown }) => { notification = data; }, - getNotifications: async () => [], + getNotifications: async () => (options.onScreen?.length ? [{ data: { ids: options.onScreen } }] : []), }, }, caches: { open: async () => ({ diff --git a/lib/__tests__/oauth-discovery.test.ts b/lib/__tests__/oauth-discovery.test.ts index 8697c7779..694b4e0e1 100644 --- a/lib/__tests__/oauth-discovery.test.ts +++ b/lib/__tests__/oauth-discovery.test.ts @@ -47,6 +47,14 @@ describe('oauth/discovery', () => { } }); + it('keeps a registration endpoint the server names, for the desktop app, and only a string', async () => { + const answer = (extra: Record) => ({ status: 200, ok: true, headers: new Headers(), json: async () => ({ ...VALID_METADATA, ...extra }) }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(answer({ registration_endpoint: 'https://auth.example.com/register' }))); + expect((await discoverOAuth('https://a.example.com', { validateEndpoint }))?.registration_endpoint).toBe('https://auth.example.com/register'); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(answer({ registration_endpoint: 7 }))); + expect((await discoverOAuth('https://b.example.com', { validateEndpoint }))?.registration_endpoint).toBeUndefined(); + }); + it('follows one redirect that stays on the same host', async () => { const redirect = { status: 301, ok: false, headers: new Headers({ location: '/auth/.well-known/openid-configuration' }), json: async () => ({}) }; const document = { status: 200, ok: true, headers: new Headers(), json: async () => VALID_METADATA }; diff --git a/lib/__tests__/push-auth-context.test.ts b/lib/__tests__/push-auth-context.test.ts new file mode 100644 index 000000000..664a273fc --- /dev/null +++ b/lib/__tests__/push-auth-context.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The context cookie lasts as long as the browser session. With every window +// closed and the browser started again by a push, only the remembered session +// is left, and the worker has to make do with that or show "New mail". +vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } })); +const maxHours = vi.hoisted(() => ({ value: 0 })); +vi.mock('@/lib/auth/session-age', () => ({ + isPastMaxAge: (signedInAt: number, hours: number) => hours > 0 && Date.now() - signedInAt > hours * 3600_000, + limitsEnforcedSince: async () => Date.now(), + maxSessionHoursFor: async () => maxHours.value, +})); +const impersonation = vi.hoisted(() => ({ master: null as string | null })); +const policy = vi.hoisted(() => ({ value: {} as Record })); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: async () => {}, getPolicy: () => policy.value }, +})); +vi.mock('@/lib/impersonation/master-config', () => ({ + readImpersonationConfig: () => (impersonation.master ? { masterPassword: impersonation.master } : null), +})); + +import { encryptSession } from '@/lib/auth/crypto'; +import { sessionCookieName } from '@/lib/auth/session-cookie'; +import { readPushAuthContext } from '@/lib/stalwart/push-auth-context'; +import { stalwartAuthContextCookieName } from '@/lib/stalwart/auth-context'; +import { encryptPayload } from '@/lib/auth/crypto'; + +function store(cookies: Record) { + return { get: (name: string) => (name in cookies ? { value: cookies[name] } : undefined) } as never; +} + +const remembered = (signedInAt = Date.now(), password = 'pw') => encryptSession('https://mail.example.org', 'me@example.org', password, signedInAt); + +beforeEach(() => { + process.env.SESSION_SECRET = 'a-test-secret-that-is-long-enough-for-this-0123456789'; + maxHours.value = 0; + impersonation.master = null; + policy.value = {}; +}); + +describe('readPushAuthContext', () => { + it('prefers the context cookie', async () => { + const context = encryptPayload({ serverUrl: 'https://ctx.example.org', username: 'ctx@example.org', authHeader: 'Bearer t' }, 'session-context'); + const found = await readPushAuthContext(store({ [stalwartAuthContextCookieName(0)]: context, [sessionCookieName(0)]: remembered() }), 0); + expect(found?.username).toBe('ctx@example.org'); + }); + + it('falls back to the remembered session', async () => { + const found = await readPushAuthContext(store({ [sessionCookieName(1)]: remembered() }), 1); + expect(found).toMatchObject({ serverUrl: 'https://mail.example.org', username: 'me@example.org' }); + expect(found?.authHeader).toMatch(/^Basic /); + }); + + it('finds nothing in another slot, or without either cookie', async () => { + expect(await readPushAuthContext(store({ [sessionCookieName(1)]: remembered() }), 0)).toBeNull(); + expect(await readPushAuthContext(store({}), 0)).toBeNull(); + }); + + it('refuses a session past the organisation\'s maximum length', async () => { + maxHours.value = 1; + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered(Date.now() - 2 * 3600_000) }), 0)).toBeNull(); + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered() }), 0)).not.toBeNull(); + }); + + it('stays out when the organisation has limits only a page enforces', async () => { + const cookies = store({ [sessionCookieName(0)]: remembered() }); + policy.value = { organisation: { session: { idleMinutes: 30 } } }; + expect(await readPushAuthContext(cookies, 0)).toBeNull(); + policy.value = { scopes: [{ match: { domains: ['example.org'] }, policy: { organisation: { session: { idleMinutes: 5 } } } }] }; + expect(await readPushAuthContext(cookies, 0)).toBeNull(); + policy.value = { scopes: [{ match: { groups: ['staff@example.org'] }, policy: { organisation: { session: { maxHours: 8 } } } }] }; + expect(await readPushAuthContext(cookies, 0)).toBeNull(); + // A maximum by domain the server checks itself, above. + policy.value = { scopes: [{ match: { domains: ['example.org'] }, policy: { organisation: { session: { maxHours: 8 } } } }] }; + expect(await readPushAuthContext(cookies, 0)).not.toBeNull(); + }); + + it('stays out for a bare user name, which policies for a domain cannot match', async () => { + const token = encryptSession('https://mail.example.org', 'john', 'pw', Date.now()); + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: token }), 0)).toBeNull(); + }); + + it('refuses garbage and a cookie holding the impersonation master password', async () => { + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: 'garbage' }), 0)).toBeNull(); + impersonation.master = 'master-secret'; + expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered(Date.now(), 'master-secret') }), 0)).toBeNull(); + }); +}); diff --git a/lib/__tests__/push-preferences.test.ts b/lib/__tests__/push-preferences.test.ts index 47a08b510..32b6d6c74 100644 --- a/lib/__tests__/push-preferences.test.ts +++ b/lib/__tests__/push-preferences.test.ts @@ -3,6 +3,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // The page mirrors the notification sound setting into the service worker's // Cache store, where sw.js reads it before showing a push notification (#1289). +describe('writeLoginQuiet', () => { + it('keeps one answer per login', async () => { + const stored = new Map(); + vi.stubGlobal('caches', { + open: async () => ({ + match: async (key: string) => (stored.has(key) ? new Response(stored.get(key)) : undefined), + put: async (key: string, res: Response) => { stored.set(key, await res.text()); }, + }), + }); + const { writeLoginQuiet } = await import('../push-preferences'); + await writeLoginQuiet('a@example.org', true); + await writeLoginQuiet('b@example.org', false); + await writeLoginQuiet('a@example.org', true); + const value = JSON.parse(stored.get(`${window.location.origin}/__push-state/quiet-logins`) ?? 'null'); + expect(value).toEqual({ 'a@example.org': true, 'b@example.org': false }); + vi.unstubAllGlobals(); + }); +}); + describe('writePushPreferences', () => { const put = vi.fn(); @@ -65,7 +84,7 @@ describe('settings store push preference mirror', () => { useSettingsStore.getState().updateSetting('emailNotificationsEnabled', true); expect(writePushPreferences).not.toHaveBeenCalled(); - // Email notifications off means no sound for push either. + // Email notifications off means no notification, so no sound either. useSettingsStore.getState().updateSetting('emailNotificationSound', true); useSettingsStore.getState().updateSetting('emailNotificationsEnabled', false); expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false }); diff --git a/lib/__tests__/push-preview-route-ambiguous.test.ts b/lib/__tests__/push-preview-route-ambiguous.test.ts index f83e8bb77..782aff8ce 100644 --- a/lib/__tests__/push-preview-route-ambiguous.test.ts +++ b/lib/__tests__/push-preview-route-ambiguous.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { beforeAll, describe, it, expect, vi, beforeEach } from 'vitest'; // The push names only the JMAP account id, and ids repeat: Stalwart numbers // accounts alike on every install, and a group account is listed in the @@ -26,13 +26,18 @@ vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 3 })); // slot 0: alice on one.example, slot 1: bob on two.example, slot 2: carol on one.example const LOGINS = [ - { serverUrl: 'https://one.example', authHeader: 'Basic alice' }, - { serverUrl: 'https://two.example', authHeader: 'Basic bob' }, - { serverUrl: 'https://one.example', authHeader: 'Basic carol' }, + { serverUrl: 'https://one.example', authHeader: 'Basic alice', username: 'alice@one.example' }, + { serverUrl: 'https://two.example', authHeader: 'Basic bob', username: 'bob@two.example' }, + { serverUrl: 'https://one.example', authHeader: 'Basic carol', username: 'carol@one.example' }, ]; +const ORIGINAL_LOGINS = [...LOGINS]; vi.mock('@/lib/stalwart/auth-context', () => ({ readStalwartAuthContextFromStore: (_store: unknown, slot: number) => LOGINS[slot] ?? null, })); +const remembered = vi.hoisted(() => ({ value: null as null | { serverUrl: string; authHeader: string; username: string } })); +vi.mock('@/lib/stalwart/push-auth-context', () => ({ + readPushAuthContext: async (_store: unknown, slot: number) => LOGINS[slot] ?? (slot === 0 ? remembered.value : null), +})); vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn(), @@ -73,10 +78,10 @@ vi.mock('@/lib/admin/config-manager', async () => { return { configManager: { ensureLoaded: async () => {}, getPolicy: () => DEFAULT_POLICY } }; }); -async function callRoute(accountId: string) { +async function callRoute(accountId: string, login?: string) { const { GET } = await import('@/app/api/push/preview/route'); const request = { - nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? 'e1' : null) } }, + nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? 'e1' : k === 'login' ? login ?? null : null) } }, headers: new Headers(), }; const res = (await GET(request as unknown as Parameters[0])) as unknown as { @@ -88,11 +93,86 @@ async function callRoute(accountId: string) { const mailCalls = () => fetchJmapServer.mock.calls.filter(([url]) => !url.endsWith('/.well-known/jmap')); +// The route's first import can take longer than a test's limit on a busy machine, +// and a test that times out keeps running into the next one's state. +beforeAll(async () => { + await import('@/app/api/push/preview/route'); +}, 60_000); + +describe('push preview after the browser was closed', () => { + it('reads the login from the remembered session when the context cookie is gone', async () => { + remembered.value = { serverUrl: 'https://one.example', authHeader: 'Basic remembered', username: 'alice@one.example' }; + LOGINS.length = 0; + try { + SESSIONS = { 'Basic remembered': session('https://one.example', 'a') }; + const { status, body } = await callRoute('a'); + expect(status).toBe(200); + expect((body.email as { subject: string }).subject).toBe('mail of Basic remembered'); + } finally { + remembered.value = null; + LOGINS.push(...ORIGINAL_LOGINS); + } + }); +}); + describe('push preview when the account id is in more than one login', () => { beforeEach(() => { fetchJmapServer.mockClear(); }); + it('does not go by the name when two servers use the same id: those logins share one relay record', async () => { + SESSIONS = { + 'Basic alice': session('https://one.example', 'a'), + 'Basic bob': session('https://two.example', 'z'), + 'Basic carol': session('https://one.example', 'z'), + }; + const { status, body } = await callRoute('z', 'BOB@two.example'); + expect(status).toBe(409); + expect(body.slot).toBeUndefined(); + }); + + it('does not answer for another login when the one the push names is gone', async () => { + SESSIONS = { + 'Basic alice': session('https://one.example', 'a'), + 'Basic bob': session('https://two.example', 'z'), + 'Basic carol': session('https://one.example', 'c'), + }; + const { status } = await callRoute('z', 'somebody-gone@two.example'); + expect(status).toBe(409); + }); + + it('takes the one login with the account when the push names it', async () => { + SESSIONS = { + 'Basic alice': session('https://one.example', 'a'), + 'Basic bob': session('https://two.example', 'z'), + 'Basic carol': session('https://one.example', 'c'), + }; + const { status, body } = await callRoute('z', 'bob@two.example'); + expect(status).toBe(200); + expect(body.slot).toBe(1); + }); + + it('takes the login the push names for a group two logins belong to', async () => { + SESSIONS = { + 'Basic alice': session('https://one.example', 'a', ['team']), + 'Basic bob': session('https://two.example', 'b'), + 'Basic carol': session('https://one.example', 'c', ['team']), + }; + const { status, body } = await callRoute('team', 'carol@one.example'); + expect(status).toBe(200); + expect(body.slot).toBe(2); + }); + + it('does not take a login that does not have the account', async () => { + SESSIONS = { + 'Basic alice': session('https://one.example', 'a', ['team']), + 'Basic bob': session('https://two.example', 'b'), + 'Basic carol': session('https://one.example', 'c', ['team']), + }; + const { status } = await callRoute('team', 'bob@two.example'); + expect(status).toBe(409); + }); + it('shows no preview and names no login when two servers use the same id', async () => { SESSIONS = { 'Basic alice': session('https://one.example', 'a'), diff --git a/lib/__tests__/push-relay-registrations.test.ts b/lib/__tests__/push-relay-registrations.test.ts new file mode 100644 index 000000000..de53872fc --- /dev/null +++ b/lib/__tests__/push-relay-registrations.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { forgetRelayRegistration, keepRelayRegistration, relayRegistrationsKey } from '@/lib/pwa-notify/worker-state'; + +const store = new Map(); + +beforeEach(() => { + store.clear(); + vi.stubGlobal('caches', { + open: async () => ({ + match: async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined), + put: async (key: string, res: Response) => { store.set(key, await res.text()); }, + }), + }); +}); + +const stored = () => JSON.parse(store.get(relayRegistrationsKey(window.location.origin)) ?? 'null'); + +describe('what the worker keeps to re-register without a tab', () => { + it('keeps one entry per account and the key', async () => { + await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, 'KEY'); + await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY'); + expect(stored()).toEqual({ + applicationServerKey: 'KEY', + entries: { a: { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, b: { relay: 'https://r', subscriptionId: 'd2' } }, + }); + }); + + it('forgets an account, and leaves the others', async () => { + await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY'); + await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY'); + await forgetRelayRegistration('a'); + expect(Object.keys(stored().entries)).toEqual(['b']); + }); + + it('stays out of the way without a Cache API', async () => { + vi.stubGlobal('caches', undefined); + await expect(keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY')).resolves.toBeUndefined(); + await expect(forgetRelayRegistration('a')).resolves.toBeUndefined(); + }); +}); diff --git a/lib/__tests__/shared-handoff-store.test.ts b/lib/__tests__/shared-handoff-store.test.ts index 2209e5a94..ac7af7728 100644 --- a/lib/__tests__/shared-handoff-store.test.ts +++ b/lib/__tests__/shared-handoff-store.test.ts @@ -109,3 +109,43 @@ describe('mobile hand-off with a shared store', () => { expect(await (await replica()).redeemHandoff(code, VERIFIER, T0 + 2)).toMatchObject({ ok: true }); }); }); + +describe('what a shared store holds after a sign-in stops being any use', () => { + const key = (code: string) => 'handoff:' + createHash('sha256').update(code).digest('hex'); + const DESKTOP = { flow: 'desktop-tokens' as const, server_url: 'https://mail.example.org', server_id: null, custom: false, access_token: 'a', refresh_token: 'r', expires_in: 60, signed_in_at: 1 }; + + it('a record with a sign-in in it is gone from the store at its real expiry, and a late code is still told so', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(T0); + const { stashHandoff, redeemHandoff, HANDOFF_TTL_MS } = await replica(); + const code = await stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's' }, T0); + expect(await getStore().kv.get(key(code))).not.toBeNull(); + vi.setSystemTime(T0 + HANDOFF_TTL_MS + 1); + expect(await getStore().kv.get(key(code))).toBeNull(); + expect(await redeemHandoff(code, VERIFIER, T0 + HANDOFF_TTL_MS + 1)).toEqual({ ok: false, reason: 'expired' }); + vi.useRealTimers(); + }); + + it('a confirmed code stays no longer than its 60 seconds', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(T0); + const { stashHandoff, confirmHandoff, HANDOFF_TTL_MS } = await replica(); + const code = await stashHandoff(DESKTOP, { challenge: CHALLENGE, state: 's', awaitConfirm: true }, T0); + expect(await confirmHandoff(code, T0)).toBe(true); + vi.setSystemTime(T0 + HANDOFF_TTL_MS + 1); + expect(await getStore().kv.get(key(code))).toBeNull(); + vi.useRealTimers(); + }); + + it('a discard takes the sign-in out at once, gives it back once, and only the desktop\'s', async () => { + const { stashHandoff, discardDesktopHandoff, redeemHandoff } = await replica(); + const code = await stashHandoff(DESKTOP, { challenge: CHALLENGE, state: 's', awaitConfirm: true }); + const other = await replica(); + expect(await other.discardDesktopHandoff(code)).toMatchObject({ refresh_token: 'r' }); + expect(await discardDesktopHandoff(code)).toBeNull(); + expect((await redeemHandoff(code, VERIFIER)).ok).toBe(false); + const phone = await stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's' }); + expect(await discardDesktopHandoff(phone)).toBeNull(); + expect((await redeemHandoff(phone, VERIFIER)).ok).toBe(true); + }); +}); diff --git a/lib/__tests__/sso-complete-route.test.ts b/lib/__tests__/sso-complete-route.test.ts index 22b508fd3..ab7e61a93 100644 --- a/lib/__tests__/sso-complete-route.test.ts +++ b/lib/__tests__/sso-complete-route.test.ts @@ -224,3 +224,62 @@ describe('sso/complete mobile hand-off while the shared store is down (#1066)', expect(JSON.stringify(res.body)).not.toMatch(/"at"|"rt"|mobile_handoff_code/); }); }); + +describe('/api/auth/sso/complete for the desktop app', () => { + const VERIFIER = 'x'.repeat(50); + const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url'); + const LISTENER = 'http://127.0.0.1:51234/bulwark-desktop/callback'; + + it('parks the tokens under a code, writes nothing in this browser, and only a confirmed code redeems', async () => { + exchangeCodeForTokens.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', id_token: 'idt', expires_in: 3600 }); + pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE }); + const res = await complete(); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + mobile_handoff_code: expect.stringMatching(/^[0-9a-f]{64}$/), + server_url: 'https://mail.example.org', + account: '', + mobile_redirect_uri: LISTENER, + mobile_state: 'd-1', + }); + expect(JSON.stringify(res.body)).not.toMatch(/"at"|"rt"|idt/); + expect(jar.has('jmap_rt')).toBe(false); + expect(jar.has('sso_pending')).toBe(false); + + expect((await redeemHandoff(res.body.mobile_handoff_code, VERIFIER)).ok).toBe(false); + }); + + it('names the account for the question: from the id token, or the server\'s session', async () => { + const jwt = (claims: object) => `x.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.y`; + exchangeCodeForTokens.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', id_token: jwt({ email: 'alice@example.org' }), expires_in: 3600 }); + pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE }); + expect((await complete()).body.account).toBe('alice@example.org'); + + const fetched = vi.fn(async () => new Response(JSON.stringify({ username: 'bob@example.org' }), { status: 200 })); + vi.stubGlobal('fetch', fetched); + exchangeCodeForTokens.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }); + pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE }); + expect((await complete()).body.account).toBe('bob@example.org'); + expect(fetched).toHaveBeenCalledWith('https://mail.example.org/.well-known/jmap', expect.objectContaining({ headers: { Authorization: 'Bearer at' } })); + vi.unstubAllGlobals(); + }); + + it('hands the refresh token itself to the app, which writes its own cookie', async () => { + exchangeCodeForTokens.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', id_token: 'idt', expires_in: 3600 }); + pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE, server_id: 'second' }); + const res = await complete(); + expect(await confirmHandoff(res.body.mobile_handoff_code)).toBe(true); + expect(await redeemHandoff(res.body.mobile_handoff_code, VERIFIER)).toMatchObject({ + ok: true, + state: 'd-1', + bundle: { flow: 'desktop-tokens', server_id: 'second', access_token: 'at', refresh_token: 'rt', id_token: 'idt' }, + }); + }); + + it('fails rather than hands over tokens if a pending cookie has no challenge', async () => { + pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1' }); + const res = await complete(); + expect(res.status).toBe(401); + expect(JSON.stringify(res.body)).not.toMatch(/"at"|"rt"/); + }); +}); diff --git a/lib/__tests__/sso-start-route.test.ts b/lib/__tests__/sso-start-route.test.ts index 6caf98a87..f1f114308 100644 --- a/lib/__tests__/sso-start-route.test.ts +++ b/lib/__tests__/sso-start-route.test.ts @@ -66,8 +66,9 @@ vi.mock('@/lib/auth/session-secret', () => ({ hasSessionSecret: () => true, })); +const configValues = vi.hoisted(() => ({ desktopBrowserSignIn: true } as Record)); vi.mock('@/lib/admin/config-manager', () => ({ - configManager: { get: () => '' }, + configManager: { get: (key: string) => (key in configValues ? configValues[key] : '') }, })); function mockRequest(body: Record): unknown { @@ -196,3 +197,44 @@ describe('sso start for the mobile app', () => { expect(encrypted.last).not.toHaveProperty('mobile_code_challenge'); }); }); + +describe('sso start for the desktop app', () => { + const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + const LISTENER = 'http://127.0.0.1:51234/bulwark-desktop/callback'; + const desktop = (extra: Record = {}) => callRoute({ + redirect_uri: 'https://mail.example.com/auth/callback', + mobile_redirect_uri: LISTENER, + mobile_state: 'd-1', + ...extra, + }); + + beforeEach(() => { + encrypted.last = null; + }); + + it('keeps the loopback target and its challenge for the complete route', async () => { + const { status } = await desktop({ mobile_code_challenge: CHALLENGE }); + expect(status).toBe(200); + expect(encrypted.last).toMatchObject({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE }); + }); + + it('refuses it when the instance\'s admin turned it off', async () => { + configValues.desktopBrowserSignIn = false; + const { status } = await desktop({ mobile_code_challenge: CHALLENGE }); + configValues.desktopBrowserSignIn = true; + expect(status).toBe(403); + expect(encrypted.last).toBeNull(); + }); + + it('refuses it without a challenge: tokens would come back for a link', async () => { + const { status } = await desktop(); + expect(status).toBe(400); + expect(encrypted.last).toBeNull(); + }); + + it.each(['http://127.0.0.1:51234/other', 'http://localhost:51234/bulwark-desktop/callback', 'http://evil.example/bulwark-desktop/callback'])('refuses %s as a target', async (target) => { + const { status } = await desktop({ mobile_code_challenge: CHALLENGE, mobile_redirect_uri: target }); + expect(status).toBe(400); + expect(encrypted.last).toBeNull(); + }); +}); diff --git a/lib/__tests__/standalone-guard.test.ts b/lib/__tests__/standalone-guard.test.ts index 347d8f526..32965e957 100644 --- a/lib/__tests__/standalone-guard.test.ts +++ b/lib/__tests__/standalone-guard.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { afterEach, describe, expect, it } from 'vitest'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; @@ -120,6 +120,36 @@ describe('standalone guard check', () => { expect(findForbidden(dir)).toEqual(['worktrees/webmail/data/']); }); + it('goes by the name in any case, as a case-insensitive disk and Docker context would', () => { + const dir = tempDir(); + put(dir, 'server.js'); + put(dir, '.ENV', 'SECRET=1'); + put(dir, 'Plugin-Signing.KEY', 'SECRET=1'); + put(dir, 'Data/admin.json'); + put(dir, 'LOCAL-DATA/a'); + put(dir, 'Repos/a'); + expect(findForbidden(dir)).toEqual(['.ENV', 'Data/', 'LOCAL-DATA/', 'Plugin-Signing.KEY', 'Repos/']); + }); + + it('refuses a link: it would ship whatever it points at', () => { + const dir = tempDir(); + const outside = tempDir(); + put(outside, 'admin.json'); + put(dir, 'server.js'); + symlinkSync(outside, path.join(dir, 'state'), 'junction'); + expect(findForbidden(dir)).toEqual(['state (link)']); + }); + + it('leaves the links of .next and node_modules alone: Turbopack makes those on purpose', () => { + const dir = tempDir(); + const outside = tempDir(); + put(dir, 'node_modules/a/index.js'); + mkdirSync(path.join(dir, '.next', 'node_modules'), { recursive: true }); + symlinkSync(path.join(dir, 'node_modules', 'a'), path.join(dir, '.next', 'node_modules', 'a-1234'), 'junction'); + symlinkSync(outside, path.join(dir, 'node_modules', 'b'), 'junction'); + expect(findForbidden(dir)).toEqual([]); + }); + it('removes the .env files Next copies next to server.js, then passes', () => { const dir = tempDir(); put(dir, 'worktrees/webmail/server.js'); diff --git a/lib/__tests__/sw-notification-actions.test.ts b/lib/__tests__/sw-notification-actions.test.ts index 96e94949f..4f81014f9 100644 --- a/lib/__tests__/sw-notification-actions.test.ts +++ b/lib/__tests__/sw-notification-actions.test.ts @@ -90,12 +90,13 @@ const previewOf = (unreadTotal: number) => JSON.stringify({ }); describe('the buttons a notification gets', () => { - const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number }) => { + const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number; onScreen?: string[] }) => { const w = worker({ caches: await cachesWith(opts.prefs), max: opts.max, inline: opts.inline, fetch: async () => new Response(previewOf(opts.unreadTotal ?? 1), { status: 200 }), + ...(opts.onScreen ? { notifications: [{ data: { ids: opts.onScreen } }] } : {}), }); await pushFor({})(w); expect(w.shown).toHaveLength(1); @@ -133,7 +134,7 @@ describe('the buttons a notification gets', () => { }); it('gives the group notification none: it names no message', async () => { - expect(await buttonsFor({ prefs: PREFS, unreadTotal: 4 })).toBeUndefined(); + expect(await buttonsFor({ prefs: PREFS, onScreen: ['e0'] })).toBeUndefined(); }); it('gives a message without its login none', async () => { diff --git a/lib/__tests__/sw-push-delivery.test.ts b/lib/__tests__/sw-push-delivery.test.ts new file mode 100644 index 000000000..005258e7d --- /dev/null +++ b/lib/__tests__/sw-push-delivery.test.ts @@ -0,0 +1,320 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vm from 'node:vm'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// What one new message, several at once and several accounts turn into on screen. +const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); + +type Preview = Record | null; +type Shown = { title: string; body?: unknown; data?: Record; tag: string; options: Record; close: () => void }; + +function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record; prefs?: unknown; quiet?: Record; openWindow?: (url: string) => Promise; registration?: Record } = {}) { + const listeners = new Map void>(); + const shade = new Map(); + const previews: URL[] = []; + const cacheStore = new Map(); + if (extra.prefs) cacheStore.set('https://mail.example.com/__push-state/prefs', JSON.stringify(extra.prefs)); + if (extra.quiet) cacheStore.set('https://mail.example.com/__push-state/quiet-logins', JSON.stringify(extra.quiet)); + const self = { + location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' }, + addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn), + skipWaiting: () => {}, + clients: { claim: () => {}, matchAll: async () => extra.clients ?? [], openWindow: extra.openWindow }, + registration: { + showNotification: async (title: string, options: Record) => { + const tag = String(options.tag ?? ''); + shade.set(tag, { title, body: options.body, data: options.data as Record, tag, options, close: () => shade.delete(tag) }); + }, + getNotifications: async (filter?: { tag?: string }) => [...shade.values()].filter((n) => !filter?.tag || n.tag === filter.tag), + ...extra.registration, + }, + }; + const caches = { + open: async () => ({ + match: async (key: string) => { + const value = cacheStore.get(key); + return value === undefined ? undefined : { json: async () => JSON.parse(value) }; + }, + put: async (key: string, res: { text: () => Promise }) => { cacheStore.set(key, await res.text()); }, + }), + }; + const fetchMock = vi.fn(async (input: string) => { + const url = new URL(input, 'https://mail.example.com'); + previews.push(url); + const body = preview(url); + return body ? { ok: true, json: async () => body } : { ok: false, status: 401, json: async () => ({}) }; + }); + const context = vm.createContext({ self, caches, fetch: fetchMock, URL, URLSearchParams, Response, Date, console, AbortController, setTimeout, clearTimeout, ...extra.globals }); + vm.runInContext(SW_SOURCE, context); + async function dispatch(type: string, event: Record) { + const waits: Promise[] = []; + listeners.get(type)?.({ ...event, waitUntil: (p: Promise) => waits.push(p) }); + await Promise.all(waits); + } + const push = (payload: unknown) => dispatch('push', { data: { json: () => payload } }); + return { push, dispatch, shade, previews }; +} + +const mail = (id: string, subject = 'Hello') => ({ id, threadId: `t-${id}`, from: [{ name: 'Alice' }], subject, preview: '' }); +const delivery = (accountId: string, ...emailIds: string[]) => ({ kind: 'jmap-email-push', accountLabel: 'me@example.org', accountId, emailIds }); + +afterEach(() => vi.useRealTimers()); + +describe('a state-change push naming several accounts', () => { + const stateChange = (changed: Record>, accountId = Object.keys(changed)[0]) => ({ + kind: 'jmap-state-change', accountLabel: 'me@example.org', accountId, emailIds: [], changed, + }); + + it('notifies for each account with new mail', async () => { + const { push, shade, previews } = loadWorker((url) => ({ unreadTotal: 1, slot: 0, email: mail(`m-${url.searchParams.get('accountId')}`) })); + await push(stateChange({ me: { EmailDelivery: 's1', Email: 's1' }, team: { EmailDelivery: 's2' } })); + expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me', 'team']); + expect([...shade.keys()].sort()).toEqual(['bulwark-mail:me', 'bulwark-mail:team']); + expect(shade.get('bulwark-mail:team')?.data).toMatchObject({ accountId: 'team', emailId: 'm-team' }); + }); + + it('leaves out an account that only changed some other way', async () => { + const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') })); + await push(stateChange({ quiet: { Mailbox: 's1' }, me: { EmailDelivery: 's2' } })); + expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me']); + }); + + it('still shows something for a ping without new mail in it', async () => { + const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') })); + await push(stateChange({ me: { Mailbox: 's1' } })); + expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me']); + }); +}); + +describe('what a notification counts', () => { + it('opens the one new message, however much older mail is unread', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 40, slot: 0, login: 'fp', email: mail('e1') })); + await push(delivery('a', 'e1')); + const shown = shade.get('bulwark-mail:a')!; + expect(shown.body).toBe('Hello'); + expect(shown.data).toMatchObject({ kind: 'email', emailId: 'e1', accountId: 'a', slot: 0, login: 'fp' }); + }); + + it('counts the messages it announced, not the unread mail, and opens the inbox for a group', async () => { + const { push, shade } = loadWorker((url) => ({ unreadTotal: 40, slot: 0, email: mail(String(url.searchParams.get('emailId')), `About ${url.searchParams.get('emailId')}`) })); + await push(delivery('a', 'e1')); + await push(delivery('a', 'e2')); + let shown = shade.get('bulwark-mail:a')!; + expect(shown.body).toBe('About e2\n+1 more message'); + expect(shown.data).toMatchObject({ kind: 'mail-list', accountId: 'a', slot: 0 }); + await push(delivery('a', 'e3', 'e4')); + shown = shade.get('bulwark-mail:a')!; + expect(shown.body).toBe('About e4\n+3 more messages'); + }); + + it('starts over once the notification was dismissed', async () => { + const { push, shade } = loadWorker((url) => ({ unreadTotal: 2, slot: 0, email: mail(String(url.searchParams.get('emailId'))) })); + await push(delivery('a', 'e1')); + shade.get('bulwark-mail:a')!.close(); + await push(delivery('a', 'e2')); + expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e2' }); + }); + + it('does not count a redelivered message twice', async () => { + const { push, shade } = loadWorker((url) => ({ unreadTotal: 2, slot: 0, email: mail(String(url.searchParams.get('emailId'))) })); + await push(delivery('a', 'e1')); + await push(delivery('a', 'e1')); + expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e1' }); + expect(shade.get('bulwark-mail:a')!.options).toMatchObject({ silent: true }); + }); + + it('counts on the state-change path too', async () => { + let id = 'e1'; + const { push, shade } = loadWorker(() => ({ unreadTotal: 5, slot: 0, email: mail(id) })); + await push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } }); + expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e1' }); + id = 'e2'; + await push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's2' } } }); + expect(shade.get('bulwark-mail:a')!.body).toBe('Hello\n+1 more message'); + }); + + it('says how many new messages without naming any when details are hidden', async () => { + const { push, shade } = loadWorker((url) => ({ unreadTotal: 9, slot: 0, redacted: true, email: { id: url.searchParams.get('emailId'), threadId: 't' } })); + await push(delivery('a', 'e1')); + expect(shade.get('bulwark-mail:a')!.body).toBe('You have new mail'); + expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e1' }); + await push(delivery('a', 'e2')); + expect(shade.get('bulwark-mail:a')!.title).toBe('New mail (me@example.org)'); + expect(shade.get('bulwark-mail:a')!.body).toBe('2 new messages'); + }); +}); + +describe('with notifications off or do not disturb on', () => { + const quiet = { 'me@example.org': true }; + + it('announces nothing for that login: shows and removes at once, without asking for the message', async () => { + const { push, shade, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1', 'Secret subject') }), { quiet }); + await push(delivery('a', 'e1')); + expect(shade.size).toBe(0); + expect(previews).toHaveLength(0); + }); + + it('still announces mail for another login', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet: { 'someone-else@example.org': true } }); + await push(delivery('a', 'e1')); + expect(shade.size).toBe(1); + }); + + it('does not bring the message up later', async () => { + const off = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet }); + await off.push(delivery('a', 'e1')); + await off.push(delivery('a', 'e1')); + expect(off.shade.size).toBe(0); + }); + + it('keeps showing with only the sound off', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: false } }); + await push(delivery('a', 'e1')); + expect(shade.get('bulwark-mail:a')!.options).toMatchObject({ silent: true, renotify: false }); + }); + + it('shows for a push without a label, or with nothing stored', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet }); + await push({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] }); + expect(shade.size).toBe(1); + }); +}); + +describe('which login a push is for', () => { + it('names the login in the preview request', async () => { + const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') })); + await push(delivery('a', 'e1')); + expect(previews[0].searchParams.get('login')).toBe('me@example.org'); + }); + + it('leaves it out when the push has no label', async () => { + const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') })); + await push({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] }); + expect(previews[0].searchParams.has('login')).toBe(false); + }); +}); + +describe('when building the notification goes wrong', () => { + it('still shows one', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, login: 'fp', email: mail('e1') }), { + globals: { Notification: { get maxActions() { throw new Error('no such thing'); } } }, + }); + await push(delivery('a', 'e1')); + expect(shade.size).toBe(1); + }); + + it('shows one for a push that is no JSON at all', async () => { + const { dispatch, shade } = loadWorker(() => null); + await dispatch('push', { data: { json: () => { throw new SyntaxError('not JSON'); } } }); + expect(shade.size).toBe(1); + }); + + it('shows one for a push without any data', async () => { + const { dispatch, shade } = loadWorker(() => null); + await dispatch('push', {}); + expect(shade.size).toBe(1); + }); +}); + +describe('which window a click uses', () => { + const win = (id: string, url: string, extra: Record = {}) => { + const client = { + id, url: `https://mail.example.com${url}`, frameType: 'top-level', visibilityState: 'hidden', focused: false, + postMessage: vi.fn(), + focus: vi.fn(async () => client), + navigate: vi.fn(async () => client), + ...extra, + }; + return client; + }; + const clickOn = (w: ReturnType) => w.dispatch('notificationclick', { + notification: { tag: 'bulwark-mail:a', close() {}, data: { kind: 'email', emailId: 'e1', accountId: 'a', slot: 0 } }, + }); + + it('leaves a pop-out and a frame alone and takes the app window', async () => { + const popout = win('p', '/popout/message/x'); + const frame = win('f', '/mail/folder/inbox', { frameType: 'nested' }); + const main = win('m', '/mail/folder/inbox'); + const w = loadWorker(() => null, { clients: [popout, frame, main] }); + await clickOn(w); + expect(popout.navigate).not.toHaveBeenCalled(); + expect(frame.navigate).not.toHaveBeenCalled(); + expect(main.navigate).toHaveBeenCalledWith('/mail/message/e1?slot=0&owner=a'); + }); + + it('takes the window in front when there are several', async () => { + const back = win('b', '/mail/folder/inbox'); + const front = win('f', '/mail/folder/inbox', { focused: true, visibilityState: 'visible' }); + const w = loadWorker(() => null, { clients: [back, front] }); + await clickOn(w); + expect(front.navigate).toHaveBeenCalled(); + expect(back.navigate).not.toHaveBeenCalled(); + }); + + it('opens a new window when only a pop-out is open', async () => { + const popout = win('p', '/popout/compose'); + const openWindow = vi.fn(async () => null); + const w = loadWorker(() => null, { clients: [popout], openWindow }); + await clickOn(w); + expect(popout.navigate).not.toHaveBeenCalled(); + expect(openWindow).toHaveBeenCalledWith('/mail/message/e1?slot=0&owner=a'); + }); +}); + +describe('nothing new to say', () => { + it('removes only what it showed itself', async () => { + const { push, shade } = loadWorker(() => ({ unreadTotal: 0, email: null })); + await push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } }); + expect(shade.size).toBe(0); + }); + + it('still shows something when the browser cannot list its notifications', async () => { + const shown: string[] = []; + const w = loadWorker(() => ({ unreadTotal: 0, email: null }), { + registration: { + showNotification: async (title: string) => { shown.push(title); }, + getNotifications: async () => { throw new Error('nope'); }, + }, + }); + await w.push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } }); + expect(shown).toHaveLength(1); + }); + + it('uses its own tag, so it can never replace a real notification', async () => { + const tags: string[] = []; + const w = loadWorker(() => ({ unreadTotal: 0, email: null }), { + registration: { + showNotification: async (_title: string, options: { tag: string }) => { tags.push(options.tag); }, + getNotifications: async () => [], + }, + }); + await w.push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } }); + expect(tags).toHaveLength(1); + expect(tags[0]).not.toBe('bulwark-mail:a'); + }); +}); + +describe('a push naming several accounts, one server slow', () => { + it('does not make the others wait out its deadline', async () => { + const started: string[] = []; + let release = () => {}; + const slow = new Promise((resolve) => { release = resolve; }); + const fetchSlow = vi.fn(async (input: string) => { + const url = new URL(input, 'https://mail.example.com'); + const account = url.searchParams.get('accountId') ?? ''; + started.push(account); + if (account === 'slow') await slow; + return { ok: true, json: async () => ({ unreadTotal: 1, slot: 0, email: mail(`m-${account}`) }) }; + }); + const w = loadWorker(() => null, { globals: { fetch: fetchSlow } }); + const done = w.push({ kind: 'jmap-state-change', accountId: 'slow', changed: { slow: { EmailDelivery: 's' }, fast: { EmailDelivery: 's' } } }); + await vi.waitFor(() => expect(started).toEqual(['slow', 'fast'])); + await vi.waitFor(() => expect(w.shade.has('bulwark-mail:fast')).toBe(true)); + release(); + await done; + expect(w.shade.size).toBe(2); + }); +}); + +// -- end of cases -- diff --git a/lib/__tests__/sw-push-owner.test.ts b/lib/__tests__/sw-push-owner.test.ts index ade1e246a..e735dee8d 100644 --- a/lib/__tests__/sw-push-owner.test.ts +++ b/lib/__tests__/sw-push-owner.test.ts @@ -15,16 +15,16 @@ function parseTarget(target: string) { describe('push click keeps the owning mailbox (#1491)', () => { for (const owner of ['personal', 'team']) { - for (const unreadTotal of [1, 3]) { + for (const onScreen of [0, 2]) { for (const warm of [false, true]) { - it(`${owner}, ${unreadTotal} unread, ${warm ? 'existing' : 'new'} window`, async () => { + it(`${owner}, ${onScreen} announced already, ${warm ? 'existing' : 'new'} window`, async () => { const target = await pushClick(owner, async (url) => { expect(new URL(url, 'https://mail.example.test').searchParams.get('accountId')).toBe(owner); - return { slot: 0, unreadTotal, email: { id: 'same-id', threadId: 't', subject: 'Mail' } }; - }, { warm }); + return { slot: 0, unreadTotal: 3, email: { id: 'same-id', threadId: 't', subject: 'Mail' } }; + }, { warm, onScreen: Array.from({ length: onScreen }, (_, i) => `earlier-${i}`) }); const link = parseTarget(target); expect(link?.slot).toBe(0); - if (unreadTotal === 1) { + if (onScreen === 0) { expect(link).toMatchObject({ kind: 'message', id: 'same-id', ownerAccountId: owner }); } else { expect(link).toMatchObject({ kind: 'folder', ref: 'inbox', ownerAccountId: owner }); @@ -42,7 +42,7 @@ describe('push click keeps the owning mailbox (#1491)', () => { }); it('keeps the login slot and owner with a mount prefix and legacy payload', async () => { - const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 3, email: { id: 'same-id' } }), { basePath: '/webmail', legacy: true }); + const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 3, email: { id: 'same-id' } }), { basePath: '/webmail', legacy: true, onScreen: ['earlier'] }); expect(target).toMatch(/^\/webmail\/mail\/folder\/inbox\?/); expect(parseTarget(target)).toMatchObject({ kind: 'folder', ownerAccountId: 'team', slot: 2 }); }); diff --git a/lib/__tests__/sw-push-redacted.test.ts b/lib/__tests__/sw-push-redacted.test.ts index 7f0477b3f..36fd14892 100644 --- a/lib/__tests__/sw-push-redacted.test.ts +++ b/lib/__tests__/sw-push-redacted.test.ts @@ -45,10 +45,10 @@ describe('service worker with a redacted preview', () => { expect(calls[0].options.data).toMatchObject({ kind: 'email', emailId: 'e1' }); }); - it('counts several unread messages without naming any', async () => { + it('opens the one new message however much else is unread', async () => { const { push, calls } = loadWorker({ unreadTotal: 3, redacted: true, email: { id: 'e1', threadId: 't1' } }); await push(delivery); expect(calls[0].title).toBe('New mail'); - expect(calls[0].options.body).toBe('3 unread messages'); + expect(calls[0].options.data).toMatchObject({ kind: 'email', emailId: 'e1' }); }); }); diff --git a/lib/__tests__/sw-push-subscription-change.test.ts b/lib/__tests__/sw-push-subscription-change.test.ts index b379d6c85..50eb98290 100644 --- a/lib/__tests__/sw-push-subscription-change.test.ts +++ b/lib/__tests__/sw-push-subscription-change.test.ts @@ -7,9 +7,11 @@ import { describe, expect, it, vi } from 'vitest'; // in a VM with a minimal ServiceWorkerGlobalScope and fire the event. const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); -function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) { +function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registrations?: unknown, config: { noThirdPartyTraffic?: boolean } | null = {}) { const listeners = new Map void>(); - const subscribe = vi.fn(async () => ({})); + const newSubscription = { toJSON: () => ({ endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } }) }; + const subscribe = vi.fn(async (_options: { userVisibleOnly?: boolean; applicationServerKey?: Uint8Array }) => newSubscription); + const relayCalls: Array<{ url: string; body: unknown }> = []; const self = { location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' }, addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn), @@ -18,7 +20,18 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) { registration: { pushManager: { subscribe } }, }; const context = vm.createContext({ - self, caches: {}, fetch: vi.fn(), URL, URLSearchParams, Response, Date, console, + self, + atob, + caches: { open: async () => ({ match: async () => (registrations ? { json: async () => registrations } : undefined) }) }, + fetch: vi.fn(async (url: string, init?: { body: string }) => { + if (url.endsWith('/api/config')) { + if (!config) throw new TypeError('offline'); + return { ok: true, json: async () => config }; + } + relayCalls.push({ url, body: JSON.parse(init!.body) }); + return { ok: true }; + }), + URL, URLSearchParams, Response, Date, console, Uint8Array, }); vm.runInContext(SW_SOURCE, context); @@ -27,13 +40,57 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) { listeners.get(type)?.({ ...event, waitUntil: (p: Promise) => waits.push(p) }); await Promise.all(waits); } - return { fire, subscribe }; + return { fire, subscribe, relayCalls, newSubscription }; } // The push service dropped or replaced the subscription while no page was // involved (#1321). The worker puts a subscription back and tells the open // tabs, which hold the login needed to register the new endpoint. describe('service worker pushsubscriptionchange', () => { + const REGISTRATIONS = { + applicationServerKey: 'QUJD', + entries: { + a: { relay: 'https://relay.example', subscriptionId: 'device-a', accountLabel: 'a@example.org' }, + b: { relay: 'https://relay.example', subscriptionId: 'device-b', accountLabel: 'b@example.org' }, + }, + }; + + it('tells the relay the new endpoint itself, for every account, with no tab open', async () => { + const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); + expect(relayCalls).toEqual([ + { url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-a', subscription: newSubscription.toJSON(), accountLabel: 'a@example.org' } }, + { url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-b', subscription: newSubscription.toJSON(), accountLabel: 'b@example.org' } }, + ]); + }); + + it('subscribes with the kept key when the browser gives no old subscription', async () => { + const { fire, subscribe, relayCalls } = loadWorker([], REGISTRATIONS); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription: null }); + expect(subscribe).toHaveBeenCalledTimes(1); + expect(subscribe.mock.calls[0][0]).toMatchObject({ userVisibleOnly: true }); + expect(Array.from(subscribe.mock.calls[0][0].applicationServerKey!)).toEqual([65, 66, 67]); + expect(relayCalls).toHaveLength(2); + }); + + it('calls no relay while third-party traffic is off: the page moves it', async () => { + const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS, { noThirdPartyTraffic: true }); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); + expect(relayCalls).toEqual([]); + }); + + it('calls no relay when it cannot find out', async () => { + const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS, null); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); + expect(relayCalls).toEqual([]); + }); + + it('calls no relay for a browser where push was never on', async () => { + const { fire, relayCalls, newSubscription } = loadWorker([]); + await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription }); + expect(relayCalls).toEqual([]); + }); + it('re-subscribes with the old key and tells the open tabs to re-register', async () => { const tab = { postMessage: vi.fn() }; const { fire, subscribe } = loadWorker([tab]); diff --git a/lib/__tests__/web-push.test.ts b/lib/__tests__/web-push.test.ts index ac95771cf..ec5434d5a 100644 --- a/lib/__tests__/web-push.test.ts +++ b/lib/__tests__/web-push.test.ts @@ -6,7 +6,9 @@ import { RateLimitError, RequestTimeoutError } from '@/lib/jmap/client'; import { buildEmailPushConfig, disableWebPush, + dropAllWebPushRegistrations, enableWebPush, + endWebPushForSignOut, isWebPushEnabled, listPushDevices, mailAccountsWithoutPush, @@ -16,8 +18,10 @@ import { revokePushDevice, waitForWebPushResync, } from '@/lib/web-push'; +import { webPushErrorKey } from '@/lib/web-push-errors'; import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays'; import { setNoThirdPartyTraffic } from '@/lib/third-party-mode'; +import { keepRelayRegistration } from '@/lib/pwa-notify/worker-state'; const RELAY = 'https://relay.example'; const RELAY_BASE_KEY = `bulwark.push.relayBase.v1.${'acct-1'}`; @@ -275,7 +279,7 @@ function installFetch(activeByDevice: Record) { if (url.includes('/api/push/active/')) { const device = url.split('/api/push/active/')[1]; const state = activeByDevice[device]; - if (state === undefined || state === 'unknown') return json({ error: 'Unknown' }, 404); + if (state === undefined || state === 'unknown') return json({ error: 'Unknown subscription' }, 404); return json({ active: state }); } return json({}); @@ -472,6 +476,532 @@ describe('enableWebPush', () => { // Registrations made before the client learned about the delivery filter are // repaired in the background on app start - nobody should have to find the // settings toggle to stop the spam pushes. +describe('enableWebPush when the verification does not come', () => { + // The relay's poll stays empty until `arrivesAfterTouch` calls come in, like a + // Stalwart that skipped the verification until the subscription is touched. + function installSilentRelay(codeOnceTouched: () => boolean) { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response; + if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' }); + if (url.includes('/api/push/verify/')) return json({ verificationCode: codeOnceTouched() ? 'late-code' : null }); + return json({}); + }); + vi.stubGlobal('fetch', fetchMock); + } + + afterEach(() => { vi.useRealTimers(); }); + + it('keeps polling through a network blip', async () => { + vi.useFakeTimers(); + const client = makeClient([]); + let polls = 0; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response; + if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' }); + if (url.includes('/api/push/verify/')) { + polls += 1; + if (polls === 1) throw new TypeError('Failed to fetch'); + return json({ verificationCode: 'code' }); + } + return json({}); + }); + vi.stubGlobal('fetch', fetchMock); + + const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', (error: Error) => error.message); + await vi.advanceTimersByTimeAsync(5_000); + + expect(await settled).toBe('done'); + expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'code'); + }); + + it('removes its unverified subscription and reads as off when the code never comes', async () => { + vi.useFakeTimers(); + const client = makeClient([]); + installSilentRelay(() => false); + + const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', (error: Error) => error.message); + await vi.advanceTimersByTimeAsync(120_000); + + expect(await settled).toMatch(/verif/i); + expect(client.destroyed).toEqual(['push-new']); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + expect(client.verifyPushSubscription).not.toHaveBeenCalled(); + }); + + it('removes its subscription when the server refuses the code', async () => { + const client = makeClient([]); + installFetch({}); + (client.verifyPushSubscription as ReturnType).mockRejectedValueOnce(new Error('Verification code does not match.')); + + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow('does not match'); + + expect(client.destroyed).toEqual(['push-new']); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('keeps the old subscription working when its replacement fails verification', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + installFetch({}); + (client.verifyPushSubscription as ReturnType).mockRejectedValueOnce(new Error('nope')); + + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false, forceRecreate: true })).rejects.toThrow('nope'); + + expect(client.destroyed).toEqual(['push-new']); + expect(localStorage.getItem(SUB_KEY)).toBe('push-old'); + }); + + it('touches the subscription after the server\'s minute, which makes it send the code', async () => { + vi.useFakeTimers(); + const client = makeClient([]); + installSilentRelay(() => (client.updatePushSubscription as ReturnType).mock.calls.length > 0); + + const run = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + const settled = run.then(() => 'done', (error: Error) => error.message); + await vi.advanceTimersByTimeAsync(58_000); + expect(client.updatePushSubscription).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(client.updatePushSubscription).toHaveBeenCalledTimes(1); + expect((client.updatePushSubscription as ReturnType).mock.calls[0][0]).toBe('push-new'); + await vi.advanceTimersByTimeAsync(5_000); + expect(await settled).toBe('done'); + expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'late-code'); + }); +}); + +describe('two tabs at once', () => { + // What the browser's Web Locks do: one holder at a time, in order. + function installLocks() { + let tail: Promise = Promise.resolve(); + const request = vi.fn(async (_name: string, run: () => Promise) => { + const next = tail.then(run, run); + tail = next.catch(() => undefined); + return next; + }); + Object.defineProperty(navigator, 'locks', { configurable: true, value: { request } }); + return request; + } + + afterEach(() => { + delete (navigator as unknown as { locks?: unknown }).locks; + }); + + // A server that knows what was created and destroyed, as the race needs. + function statefulClient() { + const subs: JmapPushSubscription[] = []; + const client = makeClient(subs); + let next = 0; + (client.createPushSubscription as ReturnType).mockImplementation(async (params: { deviceClientId: string }) => { + const id = `push-${++next}`; + subs.push(sub(id, params.deviceClientId)); + return id; + }); + (client.verifyPushSubscription as ReturnType).mockImplementation(async (id: string) => { + if (client.destroyed.includes(id)) throw new Error('notFound'); + }); + return client; + } + + it('makes one subscription, and neither tab fails', async () => { + installLocks(); + const client = statefulClient(); + installFetch({}); + + const results = await Promise.allSettled([ + enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }), + enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }), + ]); + + expect(results.map((r) => r.status)).toEqual(['fulfilled', 'fulfilled']); + expect(client.createPushSubscription).toHaveBeenCalledTimes(1); + expect(client.destroyed).toEqual([]); + expect(await client.listPushSubscriptions()).toHaveLength(1); + }); + + it('waits for a running enable before turning off', async () => { + const request = installLocks(); + const client = statefulClient(); + installFetch({}); + + const enabling = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + await Promise.all([enabling, disableWebPush({ client, relayBaseUrl: RELAY })]); + + expect(request).toHaveBeenCalledTimes(2); + expect(await client.listPushSubscriptions()).toEqual([]); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('still works in a browser without Web Locks', async () => { + const client = statefulClient(); + installFetch({}); + await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + expect(client.createPushSubscription).toHaveBeenCalledTimes(1); + }); +}); + +describe('what enabling and disabling say when they fail', () => { + it('tells a blocked browser from a question not answered', async () => { + const client = makeClient([]); + installFetch({}); + (globalThis as unknown as { Notification: unknown }).Notification = { permission: 'denied' }; + (window as unknown as { Notification: unknown }).Notification = { permission: 'denied' }; + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_blocked'); + + const unanswered = { permission: 'default', requestPermission: async () => 'default' }; + (globalThis as unknown as { Notification: unknown }).Notification = unanswered; + (window as unknown as { Notification: unknown }).Notification = unanswered; + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_not_granted'); + expect(client.createPushSubscription).not.toHaveBeenCalled(); + }); + + it('names an unreachable relay, and a relay that refuses', async () => { + const client = makeClient([]); + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_relay'); + + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response; + return String(input).endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({}, 500); + })); + await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_relay'); + expect(client.createPushSubscription).not.toHaveBeenCalled(); + }); + + it('names a verification that never came', async () => { + vi.useFakeTimers(); + try { + const client = makeClient([]); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response; + if (url.endsWith('/vapid-public-key')) return json({ publicKey: 'QUJD' }); + return json({ verificationCode: null }); + })); + const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).catch((e: unknown) => webPushErrorKey(e)); + await vi.advanceTimersByTimeAsync(120_000); + expect(await settled).toBe('error_verification_timeout'); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps naming the server refusals, and nothing for an unknown error', () => { + expect(webPushErrorKey(refusedMap())).toBe('error_forbidden'); + expect(webPushErrorKey(jmapSetError('x', OVER_QUOTA))).toBe('error_over_quota'); + expect(webPushErrorKey(new Error('boom'))).toBeNull(); + }); + + it('says so when turning off could not reach the server, and still ends up off here', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + installFetch({}); + (client.destroyPushSubscription as ReturnType).mockRejectedValue(new Error('offline')); + + await expect(disableWebPush({ client, relayBaseUrl: RELAY })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_teardown'); + + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('says so when turning off could not reach the relay', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + + await expect(disableWebPush({ client, relayBaseUrl: RELAY })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_teardown'); + + expect(client.destroyed).toEqual(['push-mine']); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('does not count a record the relay no longer has as a failure', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) }))); + + await expect(disableWebPush({ client, relayBaseUrl: RELAY })).resolves.toBeUndefined(); + }); + + it('keeps the browser subscription for the other account, and says nothing when all went well', async () => { + const { browserSub } = installPushBrowser(); + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem('bulwark.push.subscriptionId.v1.other-account', 'push-other'); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + installFetch({}); + + await expect(disableWebPush({ client, relayBaseUrl: RELAY })).resolves.toBeUndefined(); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); +}); + +describe('an endpoint the push service gave up on', () => { + const ENDPOINT_KEY = `bulwark.push.endpoint.v1.${ACCOUNT_ID}`; + + function registered() { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + localStorage.setItem(ENDPOINT_KEY, 'https://fcm.example/endpoint'); + return makeClient([sub('push-mine', THIS_DEVICE)]); + } + + it('gets a new subscription when the relay dropped the record for the very endpoint this browser still holds', async () => { + const { browserSub, registration } = installPushBrowser(); + const client = registered(); + installFetch({}); // The relay answers 404 for this device. + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1); + expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1); + }); + + it('does not take a relay that lacks the route for one that lost the record', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response; + if (url.includes('/api/push/active/')) return json({ error: 'Not found' }, 404); + return url.endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({}); + })); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('moves the other logins to the new endpoint, connected or not', async () => { + const { registration } = installPushBrowser(); + const moved = { endpoint: 'https://fcm.example/moved', options: { applicationServerKey: new Uint8Array([65, 66, 67]).buffer }, getKey: () => new Uint8Array([1, 2, 3]).buffer, unsubscribe: vi.fn(async () => true) }; + (registration.pushManager.subscribe as ReturnType).mockResolvedValue(moved); + const stored = new Map(); + vi.stubGlobal('caches', { + open: async () => ({ + match: async (key: string) => (stored.has(key) ? new Response(stored.get(key)) : undefined), + put: async (key: string, res: Response) => { stored.set(key, await res.text()); }, + }), + }); + await keepRelayRegistration('other-login', { relay: 'https://relay-of-other.example', subscriptionId: 'device-other', accountLabel: 'o@example.org' }, 'QUJD'); + const client = registered(); + const bodies: Array<{ url: string; body: { subscriptionId: string; subscription: { endpoint: string }; accountLabel?: string } }> = []; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response; + if (url.endsWith('/vapid-public-key')) return json({ publicKey: 'QUJD' }); + if (url.includes('/api/push/active/')) return json({ error: 'Unknown subscription' }, 404); + if (url.endsWith('/api/push/register/web')) bodies.push({ url, body: JSON.parse(String(init?.body)) }); + return json({}); + })); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + const other = bodies.find((b) => b.body.subscriptionId === 'device-other'); + expect(other?.url).toBe('https://relay-of-other.example/api/push/register/web'); + expect(other?.body.subscription.endpoint).toBe('https://fcm.example/moved'); + expect(other?.body.accountLabel).toBe('o@example.org'); + expect(bodies.filter((b) => b.body.subscriptionId === THIS_DEVICE)).toHaveLength(1); + }); + + it('keeps the subscription while the relay knows the record', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + installFetch({ [THIS_DEVICE]: true }); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('keeps it when the relay cannot be asked', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + const calls: string[] = []; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + calls.push(url); + if (url.includes('/api/push/active/')) throw new TypeError('blip'); + const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response; + return url.endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({}); + })); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(calls.some((url) => url.includes('/api/push/active/'))).toBe(true); + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); + + it('only registers when another account already moved the browser to a new endpoint', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + localStorage.setItem(ENDPOINT_KEY, 'https://fcm.example/older'); + installFetch({}); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + expect(localStorage.getItem(ENDPOINT_KEY)).toBe('https://fcm.example/endpoint'); + }); + + it('does not mistake a move to another relay for a dead endpoint', async () => { + const { browserSub } = installPushBrowser(); + const client = registered(); + localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example'); + installFetch({}); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); +}); + +describe('a subscription id the server gave to another device', () => { + // Stalwart hands out the lowest free id: this browser's subscription expired, + // and the phone that registered next got the same id. + it('creates its own instead of taking over the other device\'s', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'b'); + const client = makeClient([sub('b', OTHER_DEVICE)]); + installFetch({ [OTHER_DEVICE]: true }); + + await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(client.updatePushSubscription).not.toHaveBeenCalled(); + expect(client.createPushSubscription).toHaveBeenCalledTimes(1); + expect(client.destroyed).toEqual([]); + expect(localStorage.getItem(SUB_KEY)).toBe('push-new'); + }); + + it('leaves it alone when turning off', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'b'); + const client = makeClient([sub('b', OTHER_DEVICE)]); + installFetch({}); + + await disableWebPush({ client, relayBaseUrl: RELAY }); + + expect(client.destroyed).toEqual([]); + }); + + it('leaves it alone on Re-register', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'b'); + const client = makeClient([sub('b', OTHER_DEVICE)]); + installFetch({ [OTHER_DEVICE]: true }); + + await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false, forceRecreate: true }); + + expect(client.destroyed).toEqual([]); + }); +}); + +describe('a setup that gets stuck', () => { + afterEach(() => { + vi.useRealTimers(); + delete (navigator as unknown as { locks?: unknown }).locks; + }); + + it('fails in the end and lets the next one through', async () => { + vi.useFakeTimers(); + let tail: Promise = Promise.resolve(); + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { request: (_n: string, run: () => Promise) => { const next = tail.then(run, run); tail = next.catch(() => undefined); return next; } }, + }); + const { registration } = installPushBrowser(); + (registration.pushManager.getSubscription as ReturnType).mockResolvedValueOnce(null); + (registration.pushManager.subscribe as ReturnType).mockReturnValueOnce(new Promise(() => {})); + const client = makeClient([]); + installFetch({}); + + const stuck = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', () => 'failed'); + await vi.advanceTimersByTimeAsync(1_000); + const second = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', () => 'failed'); + await vi.advanceTimersByTimeAsync(200_000); + + expect(await stuck).toBe('failed'); + expect(await second).toBe('done'); + }); + + it('turns off for the account it was asked about, even if the client disconnected while waiting', async () => { + let tail: Promise = Promise.resolve(); + let release = () => {}; + tail = new Promise((resolve) => { release = resolve; }); + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { request: (_n: string, run: () => Promise) => { const next = tail.then(run, run); tail = next.catch(() => undefined); return next; } }, + }); + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + let accountId = ACCOUNT_ID; + (client as unknown as { getAccountId: () => string }).getAccountId = () => accountId; + const calls = installFetch({}); + + const off = disableWebPush({ client, relayBaseUrl: RELAY }).catch(() => undefined); + accountId = ''; + release(); + await off; + + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + expect(calls).toContainEqual({ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }); + }); +}); + +describe('a re-sync waiting its turn', () => { + afterEach(() => { delete (navigator as unknown as { locks?: unknown }).locks; }); + + it('does not bring back what another tab turned off meanwhile', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + installFetch({ [THIS_DEVICE]: true }); + let turn: Promise = Promise.resolve(); + let release = () => {}; + turn = new Promise((resolve) => { release = resolve; }); + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { request: (_n: string, run: () => Promise) => { const next = turn.then(run, run); turn = next.catch(() => undefined); return next; } }, + }); + + const resync = resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + await vi.waitFor(() => expect((navigator as unknown as { locks: unknown }).locks).toBeTruthy()); + await new Promise((resolve) => setTimeout(resolve, 0)); + localStorage.removeItem(SUB_KEY); // The other tab, holding the lock. + release(); + await resync; + + expect(client.updatePushSubscription).not.toHaveBeenCalled(); + expect(client.createPushSubscription).not.toHaveBeenCalled(); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); +}); + +describe('a registration from before the relay was remembered', () => { + it('gets the relay noted when it is refreshed', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + installFetch({ [THIS_DEVICE]: true }); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(localStorage.getItem(RELAY_BASE_KEY)).toBe(RELAY); + expect(client.createPushSubscription).not.toHaveBeenCalled(); + }); +}); + describe('resyncWebPush when the relay changed (#903)', () => { afterEach(() => setNoThirdPartyTraffic(false)); @@ -504,6 +1034,48 @@ describe('resyncWebPush when the relay changed (#903)', () => { expect(client.destroyed).toContain('push-old'); }); + it('leaves no record on the relay it moved away from', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example'); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + const calls = installFetch({}); + + expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true); + + expect(calls).toContainEqual({ url: `https://old-relay.example/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }); + expect(calls).not.toContainEqual({ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }); + expect(localStorage.getItem(RELAY_BASE_KEY)).toBe(RELAY); + expect(client.destroyed).toEqual(['push-old']); + }); + + it('keeps the old relay until the new subscription is verified', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example'); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + const calls = installFetch({}); + (client.verifyPushSubscription as ReturnType).mockRejectedValueOnce(new Error('nope')); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(calls.some((c) => c.method === 'DELETE')).toBe(false); + expect(localStorage.getItem(RELAY_BASE_KEY)).toBe('https://old-relay.example'); + }); + + it('does not contact the built-in relay to clean up when third-party traffic is off', async () => { + setNoThirdPartyTraffic(true); + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-old'); + localStorage.setItem(RELAY_BASE_KEY, DEFAULT_RELAY_BASE_URL); + const client = makeClient([sub('push-old', THIS_DEVICE)]); + const calls = installFetch({}); + + await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }); + + expect(calls.filter((c) => c.url.startsWith(DEFAULT_RELAY_BASE_URL))).toEqual([]); + }); + it('re-registers inside the daily limit when the admin switched relays, and only once', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); localStorage.setItem(SUB_KEY, 'push-old'); @@ -1388,6 +1960,18 @@ describe('disableWebPush', () => { expect(localStorage.getItem(REFUSED_KEY)).toBeNull(); }); + it('drops the record on the relay it was registered with, not only the current one', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-tracked'); + localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example'); + const client = makeClient([sub('push-tracked', THIS_DEVICE)]); + const calls = installFetch({}); + + await disableWebPush({ client, relayBaseUrl: RELAY }); + + expect(calls).toContainEqual({ url: `https://old-relay.example/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }); + }); + it('drops the relay registration for this device', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); const client = makeClient([]); @@ -1402,6 +1986,84 @@ describe('disableWebPush', () => { }); }); +describe('endWebPushForSignOut', () => { + it('takes everything down for a login with push on, using the relay it registered with', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + const calls = installFetch({}); + + await endWebPushForSignOut(client); + + expect(client.destroyed).toEqual(['push-mine']); + expect(calls).toEqual([{ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }]); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('does nothing and calls nobody for a login without push', async () => { + const client = makeClient([]); + const calls = installFetch({}); + + await endWebPushForSignOut(client); + + expect(calls).toEqual([]); + expect(client.listPushSubscriptions).not.toHaveBeenCalled(); + }); + + it('never throws: signing out goes on', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('offline'); })); + (client.destroyPushSubscription as ReturnType).mockRejectedValue(new Error('offline')); + + await expect(endWebPushForSignOut(client)).resolves.toBeUndefined(); + expect(localStorage.getItem(SUB_KEY)).toBeNull(); + }); + + it('forgets the key material with nobody left', async () => { + localStorage.setItem(DEVICE_KEY, THIS_DEVICE); + localStorage.setItem(SUB_KEY, 'push-mine'); + localStorage.setItem(RELAY_BASE_KEY, RELAY); + const { browserSub } = installPushBrowser(); + const client = makeClient([sub('push-mine', THIS_DEVICE)]); + installFetch({}); + + await endWebPushForSignOut(client); + + expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1); + }); +}); + +describe('dropAllWebPushRegistrations', () => { + it('takes down every login this browser registered, connected or not, and calls no relay it did not use', async () => { + const { browserSub } = installPushBrowser(); + localStorage.setItem('bulwark.push.subscriptionId.v1.a', 'x'); + localStorage.setItem('bulwark.push.deviceClientId.v1.a', 'device-a'); + localStorage.setItem('bulwark.push.relayBase.v1.a', RELAY); + localStorage.setItem('bulwark.push.subscriptionId.v1.b', 'y'); + localStorage.setItem('bulwark.push.deviceClientId.v1.b', 'device-b'); + const calls = installFetch({}); + + await dropAllWebPushRegistrations(); + + expect(calls).toEqual([{ url: `${RELAY}/api/push/register/device-a`, method: 'DELETE' }]); + expect(localStorage.getItem('bulwark.push.subscriptionId.v1.a')).toBeNull(); + expect(localStorage.getItem('bulwark.push.subscriptionId.v1.b')).toBeNull(); + expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('does nothing with nothing registered', async () => { + const { browserSub } = installPushBrowser(); + const calls = installFetch({}); + await dropAllWebPushRegistrations(); + expect(calls).toEqual([]); + expect(browserSub.unsubscribe).not.toHaveBeenCalled(); + }); +}); + describe('listPushDevices', () => { it('flags this device and reports the relay status of each registration', async () => { localStorage.setItem(DEVICE_KEY, THIS_DEVICE); diff --git a/lib/admin/password.ts b/lib/admin/password.ts index bae73d0b5..a2bfdce13 100644 --- a/lib/admin/password.ts +++ b/lib/admin/password.ts @@ -6,6 +6,7 @@ import { assertWritable, isConfigReadOnly } from './paths'; import { adminConfigDocs, adminStateDocs, isStoreUnavailable } from './admin-store'; import type { AdminConfigData, AdminStateData } from './types'; import { readFileEnv } from '../read-file-env'; +import { desktopServerSecret } from '@/lib/desktop/server-secrets'; const SCRYPT_KEYLEN = 64; const SCRYPT_COST = 16384; // 2^14 @@ -269,7 +270,7 @@ export async function initAdminPassword(): Promise { return true; } - const envPassword = process.env.ADMIN_PASSWORD; + const envPassword = desktopServerSecret('adminPassword') || process.env.ADMIN_PASSWORD; if (!envPassword) { if (!cache.initialized) logger.info('Admin dashboard disabled (no ADMIN_PASSWORD set)'); cache.initialized = true; diff --git a/lib/admin/types.ts b/lib/admin/types.ts index d6bb72231..be40d0c52 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -300,6 +300,9 @@ export const CONFIG_ENV_MAP: Record false, 'handoff-store': () => false, 'custom-upstream': () => false, + 'plugin-wipe-proof': () => false, }; export function encryptPayload(payload: Record, purpose: PayloadPurpose): string { diff --git a/lib/auth/desktop-handoff-revoke.ts b/lib/auth/desktop-handoff-revoke.ts new file mode 100644 index 000000000..cb325bbcf --- /dev/null +++ b/lib/auth/desktop-handoff-revoke.ts @@ -0,0 +1,33 @@ +import { logger } from '@/lib/logger'; +import type { ParkedHandoff } from '@/lib/auth/handoff-store'; +import { discoverOAuth } from '@/lib/oauth/discovery'; +import { buildOAuthParams, DEFAULT_CLIENT_ID, getDiscoveryValidator, getRequiredConfig } from '@/lib/oauth/token-exchange'; + +const REVOCATION_TIMEOUT_MS = 3000; + +/** + * A refresh token that was minted and then not wanted: declined, or refused by the device's + * policy. Revoked where the provider has an endpoint for it (Stalwart has none, and there the + * token just stops being held). Never throws: nobody is waiting to hear about a failure. + */ +export async function revokeParkedTokens(parked: ParkedHandoff): Promise { + if (parked.flow !== 'desktop-tokens' || !parked.refresh_token) return; + try { + const options = { fallbackClientId: DEFAULT_CLIENT_ID }; + const { discoveryUrl } = getRequiredConfig(parked.server_id, options); + const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator(discoveryUrl) }); + if (!metadata?.revocation_endpoint) return; + const params = buildOAuthParams({ token: parked.refresh_token, token_type_hint: 'refresh_token' }, parked.server_id, options); + // The body holds the refresh token: no redirect is followed with it. + const response = await fetch(metadata.revocation_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + signal: AbortSignal.timeout(REVOCATION_TIMEOUT_MS), + redirect: 'error', + }); + if (!response.ok) logger.warn('Declined token revocation returned error', { status: response.status }); + } catch (error) { + logger.warn('Declined token revocation failed', { error: error instanceof Error ? error.message : 'Unknown error' }); + } +} diff --git a/lib/auth/desktop-handoff-server.ts b/lib/auth/desktop-handoff-server.ts new file mode 100644 index 000000000..770820ff0 --- /dev/null +++ b/lib/auth/desktop-handoff-server.ts @@ -0,0 +1,63 @@ +import { configManager } from '@/lib/admin/config-manager'; +import { findServerById, findServerByUrl, parseJmapServers, resolveTrustedJmapUrl, type JmapServerEntry } from '@/lib/admin/jmap-servers'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { isPublicHttpUrl } from '@/lib/security/url-guard'; + +/** + * What the desktop app's sign-in routes share (app/api/auth/desktop-handoff). The same + * pinning as the other sign-in routes, and the instance admin's switch: on a terminal + * server another local user can bind a loopback port, so the admin can turn the hand-off + * off there. A device's own `BrowserSignIn` doesn't reach this: it's about the app, and + * the other user's link opens in a browser. + */ +export interface DesktopHandoffRefusal { + error: string; + status: number; +} + +export function desktopHandoffEnabled(): boolean { + return !IS_DESKTOP_BUILD && configManager.get('desktopBrowserSignIn', true); +} + +export type DesktopHandoffServer = + | { refusal: DesktopHandoffRefusal } + | { + entry: JmapServerEntry | undefined; + /** What's checked and parked, whatever the body said. */ + resolved: string; + upstream: string; + trusted: boolean; + /** The user's own server, by `allowCustomJmapEndpoint`. A device's policy may refuse it. */ + custom: boolean; + }; + +export async function resolveDesktopHandoffServer(serverUrl: string, serverId: unknown): Promise { + await configManager.ensureLoaded(); + if (!desktopHandoffEnabled()) return { refusal: { error: 'disabled', status: 403 } }; + if (configManager.get('oauthEnabled', false) && configManager.get('oauthOnly', false)) { + return { refusal: { error: 'oauth_only', status: 403 } }; + } + const configured = + configManager.get('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || ''; + const list = parseJmapServers(configManager.get('jmapServers', [])); + const requested = typeof serverId === 'string' && serverId ? serverId : null; + const entry = findServerById(list, requested) || findServerByUrl(list, serverUrl) || undefined; + let resolved: string; + let trusted = true; + let custom = false; + if (entry) { + resolved = entry.url; + } else { + const pinned = resolveTrustedJmapUrl(serverUrl, configured, list); + if (pinned) { + resolved = pinned; + } else if (configManager.get('allowCustomJmapEndpoint', false) && (await isPublicHttpUrl(serverUrl))) { + resolved = serverUrl; + trusted = false; + custom = true; + } else { + return { refusal: { error: 'invalid_server_url', status: 400 } }; + } + } + return { entry, resolved, upstream: resolved.replace(/\/+$/, ''), trusted, custom }; +} diff --git a/lib/auth/handoff-store.ts b/lib/auth/handoff-store.ts index ba0a8fc0e..4b9030d0e 100644 --- a/lib/auth/handoff-store.ts +++ b/lib/auth/handoff-store.ts @@ -59,9 +59,41 @@ export function isHandoffCode(value: unknown): value is string { return typeof value === 'string' && CODE_RE.test(value); } +/** + * What the desktop app's sign-in parks (app/api/auth/desktop-handoff). The app's redeem + * writes its own cookies from this, so these fields never go to anyone else: the mobile + * redeem route refuses them. + */ +export type DesktopHandoffBundle = + | { + flow: 'desktop-password'; + server_url: string; + server_id: string | null; + /** The user's own server, which a device's policy may refuse. */ + custom: boolean; + username: string; + password: string; + } + | { + flow: 'desktop-tokens'; + server_url: string; + server_id: string | null; + custom: boolean; + /** From a typed password, not a provider's page: a password account, as the user knows it. */ + password_login?: boolean; + username?: string; + access_token: string; + refresh_token?: string; + id_token?: string; + expires_in: number; + signed_in_at: number; + }; + +export type ParkedHandoff = RedeemBundle | DesktopHandoffBundle; + interface HandoffRecord { /** Null once redeemed (or tried) or expired. */ - bundle: RedeemBundle | null; + bundle: ParkedHandoff | null; state: string; challenge: string; /** Whether the user confirmed the hand-off; only confirmed codes redeem. */ @@ -72,7 +104,7 @@ interface HandoffRecord { } export type HandoffRedeemResult = - | { ok: true; bundle: RedeemBundle; state: string } + | { ok: true; bundle: ParkedHandoff; state: string } | { ok: false; reason: 'invalid' | 'expired' | 'verifier_mismatch' }; const STORE_KEY = '__bulwarkMobileHandoffStore'; @@ -83,8 +115,9 @@ function store(): Map { return g[STORE_KEY]; } -// Wipe sign-ins past their expiry and forget tombstones past theirs. Called -// on every operation, so memory stays bounded without a timer. +// Wipe sign-ins past their expiry and forget tombstones past theirs. Called on +// every operation, and by a timer at each record's expiry: a sign-in nobody asks +// about again must not stay in memory until someone happens to call in. function sweep(now: number): void { const codes = store(); for (const [code, record] of codes) { @@ -93,12 +126,17 @@ function sweep(now: number): void { } } +function wipeWhenDue(expiresAt: number, now: number): void { + const timer = setTimeout(() => sweep(Date.now()), Math.max(expiresAt - now, 0) + 1); + timer.unref?.(); +} + function s256(verifier: string): string { return createHash('sha256').update(verifier, 'ascii').digest('base64url'); } function newRecord( - bundle: RedeemBundle, + bundle: ParkedHandoff, options: { challenge: string; state: string; awaitConfirm?: boolean }, now: number, ): HandoffRecord { @@ -121,7 +159,7 @@ function confirmed(record: HandoffRecord, now: number): HandoffRecord { } /** What a redeem attempt of `record` (spent by the attempt) gets. */ -function redeemOutcome(record: HandoffRecord, bundle: RedeemBundle | null, verifier: string, now: number): HandoffRedeemResult { +function redeemOutcome(record: HandoffRecord, bundle: ParkedHandoff | null, verifier: string, now: number): HandoffRedeemResult { if (!bundle) return { ok: false, reason: record.expiresAt <= now ? 'expired' : 'invalid' }; if (!record.confirmed) return { ok: false, reason: 'invalid' }; const expected = Buffer.from(record.challenge, 'ascii'); @@ -129,7 +167,7 @@ function redeemOutcome(record: HandoffRecord, bundle: RedeemBundle | null, verif if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { return { ok: false, reason: 'verifier_mismatch' }; } - if (bundle.flow === 'oauth' && typeof bundle.expires_in === 'number') { + if ((bundle.flow === 'oauth' || bundle.flow === 'desktop-tokens') && typeof bundle.expires_in === 'number') { const elapsed = Math.floor((now - record.issuedAt) / 1000); return { ok: true, bundle: { ...bundle, expires_in: Math.max(0, bundle.expires_in - elapsed) }, state: record.state }; } @@ -139,6 +177,8 @@ function redeemOutcome(record: HandoffRecord, bundle: RedeemBundle | null, verif // ─── shared store ────────────────────────────────────────────────────────── const sharedKey = (code: string) => `handoff:${createHash('sha256').update(code).digest('hex')}`; +/** Holds no sign-in, only when it runs out. So a code past that, with its record gone, is told "expired". */ +const issuedKey = (code: string) => `handoff-issued:${createHash('sha256').update(code).digest('hex')}`; function seal(record: HandoffRecord): string { return encryptPayload({ r: record }, 'handoff-store'); @@ -153,6 +193,8 @@ function open(sealed: string | null, now: number): HandoffRecord | null { } const ttl = (record: HandoffRecord, now: number) => Math.max(record.forgetAt - now, 0) + CLEANUP_MS; +/** A record with a sign-in in it stays no longer than the sign-in is good for. */ +const secretTtl = (record: HandoffRecord, now: number) => Math.max(record.expiresAt - now, 1); // ─── API ─────────────────────────────────────────────────────────────────── @@ -161,7 +203,7 @@ const ttl = (record: HandoffRecord, now: number) => Math.max(record.forgetAt - n * `awaitConfirm` the code only becomes redeemable through confirmHandoff. */ export async function stashHandoff( - bundle: RedeemBundle, + bundle: ParkedHandoff, options: { challenge: string; state: string; awaitConfirm?: boolean }, now = Date.now(), ): Promise { @@ -170,7 +212,8 @@ export async function stashHandoff( if (isSharedMode()) { // Each code is spent by a single redeem and dies on its own; the create // budget bounds how many one client parks. - await getStore().kv.set(sharedKey(code), seal(record), { ttlMs: ttl(record, now) }); + await getStore().kv.set(sharedKey(code), seal(record), { ttlMs: secretTtl(record, now) }); + await getStore().kv.set(issuedKey(code), String(record.expiresAt), { ttlMs: ttl(record, now) }); return code; } sweep(now); @@ -182,6 +225,7 @@ export async function stashHandoff( codes.delete(oldest); } codes.set(code, record); + wipeWhenDue(record.expiresAt, now); return code; } @@ -199,16 +243,44 @@ export async function confirmHandoff(code: string, now = Date.now()): Promise { + const isDesktop = (bundle: ParkedHandoff | null) => bundle?.flow === 'desktop-password' || bundle?.flow === 'desktop-tokens'; + if (isSharedMode()) { + let taken: ParkedHandoff | null = null; + await getStore().kv.update(sharedKey(code), (current) => { + taken = null; + const record = open(current, now); + if (!record || !isDesktop(record.bundle)) return undefined; + taken = record.bundle; + return seal({ ...record, bundle: null }); + }, { ttlMs: TOMBSTONE_MS + CLEANUP_MS }); + return taken as ParkedHandoff | null; + } + sweep(now); + const record = store().get(code); + if (!record || !isDesktop(record.bundle)) return null; + const taken = record.bundle; + record.bundle = null; + return taken; +} + /** * Hand out the sign-in behind `code` when `verifier` matches its challenge. * The code is spent by the attempt, whatever its outcome. `expires_in` of an @@ -219,7 +291,10 @@ export async function redeemHandoff(code: string, verifier: string, now = Date.n const kv = getStore().kv; // Taken in one step: of two attempts, only one sees the sign-in. const record = open(await kv.take(sharedKey(code)), now); - if (!record) return { ok: false, reason: 'invalid' }; + if (!record) { + const until = await kv.get(issuedKey(code)); + return { ok: false, reason: until !== null && Number(until) <= now ? 'expired' : 'invalid' }; + } const bundle = record.expiresAt > now ? record.bundle : null; // A tombstone goes back, so a late attempt is told "expired". The code is // spent already: if the tombstone can't be written, the answer stands. @@ -249,12 +324,21 @@ export function resetHandoffStoreForTests(): void { // filling the store or hammering the redeem route. const createBudget = createFailureBudget({ name: 'handoff-create', clientMax: 10, globalMax: Infinity }); const redeemBudget = createFailureBudget({ name: 'handoff-redeem', clientMax: 10, globalMax: Infinity }); +const desktopParkBudget = createFailureBudget({ name: 'desktop-handoff-park', clientMax: 10, globalMax: Infinity }); /** Count one parked sign-in for `client`; false when it is over its limit. */ export async function reserveHandoffCreate(client: string, now = Date.now()): Promise { return (await createBudget.reserve(client, now)) !== null; } +/** + * One park for the desktop app, reserved before anything goes upstream: null when `client` is + * over. Only failures count, so everyone behind one office address can sign in. + */ +export function reserveDesktopHandoffPark(client: string, now = Date.now()): Promise { + return desktopParkBudget.reserve(client, now); +} + /** * Reserve one redeem attempt for `client`: null when it is over its limit, * otherwise a refund to call when the redeem succeeded (only failures count). diff --git a/lib/auth/mobile-handoff-client.ts b/lib/auth/mobile-handoff-client.ts index 9730bcce4..619ada9f3 100644 --- a/lib/auth/mobile-handoff-client.ts +++ b/lib/auth/mobile-handoff-client.ts @@ -28,6 +28,74 @@ export async function requestMobileHandoffCodeUrl(opts: { } } +/** The desktop app's sign-in, before its question: is this password right? Nothing is kept. */ +export async function checkDesktopSignIn(opts: { + serverUrl: string; + serverId?: string; + username: string; + password: string; +}): Promise<{ ok: true } | { error: string }> { + try { + const res = await apiFetch('/api/auth/desktop-handoff/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ server_url: opts.serverUrl, server_id: opts.serverId, username: opts.username, password: opts.password }), + }); + if (res.ok) return { ok: true }; + const data = (await res.json().catch(() => null)) as { error?: unknown } | null; + return { error: typeof data?.error === 'string' ? data.error : 'login_failed' }; + } catch { + return { error: 'network_error' }; + } +} + +/** The tab with an SSO sign-in the user hasn't answered went, or said no: it's not kept. */ +export function discardDesktopSignIn(code: string): void { + void apiFetch('/api/auth/desktop-handoff/discard', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + keepalive: true, + }).catch(() => {}); +} + +/** + * The user said yes: parks the sign-in for the app (/api/auth/desktop-handoff/park), as + * tokens wherever the server can give them. This browser gets no session out of it. + */ +export async function parkDesktopSignIn(opts: { + challenge: string; + state: string; + serverUrl: string; + serverId?: string; + username: string; + password: string; + totp?: string; + redirectUri: string; +}): Promise<{ code: string } | { error: string }> { + try { + const res = await apiFetch('/api/auth/desktop-handoff/park', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code_challenge: opts.challenge, + state: opts.state, + server_url: opts.serverUrl, + server_id: opts.serverId, + username: opts.username, + password: opts.password, + totp: opts.totp, + redirect_uri: opts.redirectUri, + }), + }); + const data = (await res.json().catch(() => null)) as { code?: unknown; error?: unknown } | null; + if (res.ok && typeof data?.code === 'string' && data.code) return { code: data.code }; + return { error: typeof data?.error === 'string' ? data.error : 'login_failed' }; + } catch { + return { error: 'network_error' }; + } +} + /** * Confirm an SSO hand-off the server parked (/api/auth/handoff/confirm) and * return the app link with its code. Null when the code is gone (it waited diff --git a/lib/auth/mobile-handoff.ts b/lib/auth/mobile-handoff.ts index 3de25976f..60966f67c 100644 --- a/lib/auth/mobile-handoff.ts +++ b/lib/auth/mobile-handoff.ts @@ -27,6 +27,36 @@ export function mobileHandoffTarget(raw: string | null | undefined): string { : ''; } +/** + * The desktop app's loopback listener (RFC 8252), the other kind of target. Only ever + * with a code: unlike the phone's link, it has no older builds that take the sign-in in a + * fragment, so without a valid challenge this isn't a hand-off at all. The code goes + * nowhere but this computer, whichever port a link names, and it's worthless without the + * verifier behind the challenge. + */ +const DESKTOP_TARGET = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{3,4})\/bulwark-desktop\/callback$/; + +export function desktopHandoffTarget(raw: string | null | undefined): string { + if (typeof raw !== 'string') return ''; + const match = DESKTOP_TARGET.exec(raw.trim()); + return match && Number(match[1]) <= 65535 ? match[0] : ''; +} + +/** The port a desktop target names, for the question the page asks. */ +export function desktopHandoffPort(target: string): string { + return DESKTOP_TARGET.exec(target)?.[1] ?? ''; +} + +/** "Not now": ends the app's flow at its listener, so it isn't left waiting. No code in it. */ +export function desktopHandoffDeclineUrl(target: string, state: string): string { + return `${target}?${new URLSearchParams({ error: 'access_denied', state }).toString()}`; +} + +/** Either kind of target, as the callback page reads it back. */ +export function handoffTarget(raw: string | null | undefined): string { + return mobileHandoffTarget(raw) || desktopHandoffTarget(raw); +} + /** The URL the browser hands to the app: the target plus the fragment. */ export function buildMobileHandoffUrl(target: string, fragment: Record): string { return `${target}#${new URLSearchParams(fragment).toString()}`; diff --git a/lib/auth/session-secret.ts b/lib/auth/session-secret.ts index cd3b6cee7..d8faeea3c 100644 --- a/lib/auth/session-secret.ts +++ b/lib/auth/session-secret.ts @@ -1,6 +1,7 @@ import { randomBytes } from 'node:crypto'; import { writeFile } from 'node:fs/promises'; import { configManager } from '@/lib/admin/config-manager'; +import { desktopServerSecret } from '@/lib/desktop/server-secrets'; import { ensureStateDir, getStatePath } from '@/lib/admin/paths'; import { readFileEnv } from '@/lib/read-file-env'; import { getStore, isSharedMode } from '@/lib/shared-store'; @@ -25,6 +26,9 @@ function sharedSecret(): string { /** A secret the operator set: env, file, or the setup wizard's config. */ function configuredSessionSecret(): string { + const fromShell = desktopServerSecret('sessionSecret'); + if (fromShell) return fromShell; + const fromEnv = process.env.SESSION_SECRET; if (fromEnv) return fromEnv; diff --git a/lib/auth/verify-jmap-auth.ts b/lib/auth/verify-jmap-auth.ts index 6c455cdf4..b565de6e0 100644 --- a/lib/auth/verify-jmap-auth.ts +++ b/lib/auth/verify-jmap-auth.ts @@ -6,6 +6,7 @@ import { } from '@/lib/security/url-guard'; import { forwardedClientHeaders } from '@/lib/auth/client-address'; import { observeUpstream } from '@/lib/metrics/upstream'; +import { desktopRedirectRefused } from '@/lib/desktop/server-redirect'; const VERIFY_TIMEOUT_MS = 10000; const MAX_REDIRECTS = 3; @@ -453,7 +454,11 @@ async function fetchVerifiedSession( if (!location) { throw new JmapAuthVerificationError('Failed to verify JMAP session', 502); } - currentUrl = new URL(location, currentUrl).toString(); + const next = new URL(location, currentUrl).toString(); + if (desktopRedirectRefused(currentUrl, next, options.trusted === true)) { + throw new JmapAuthVerificationError('The mail server redirected to a server that has not been approved', 502); + } + currentUrl = next; continue; } break; diff --git a/lib/config-validation.ts b/lib/config-validation.ts index a00a638e1..b2b79dc91 100644 --- a/lib/config-validation.ts +++ b/lib/config-validation.ts @@ -56,6 +56,7 @@ const configValidators = { loginShowSubtitle: isBoolean, loginShowTotp: isBoolean, loginShowTokenLogin: isBoolean, + desktopBrowserSignIn: isBoolean, loginShowRememberMe: isBoolean, loginShowVersion: isBoolean, demoMode: isBoolean, diff --git a/lib/desktop/__tests__/browser-side.test.ts b/lib/desktop/__tests__/browser-side.test.ts index 6d7d8d23a..9a7136815 100644 --- a/lib/desktop/__tests__/browser-side.test.ts +++ b/lib/desktop/__tests__/browser-side.test.ts @@ -144,79 +144,212 @@ describe('JMAPClient on an approved http server', () => { }); describe('runDesktopBrowserSignIn', () => { - const ENDPOINTS = { authorization_endpoint: 'https://mail.example/authorize', client_id: 'registered-client', scope: 'openid email' }; + const ENDPOINTS = { authorization_endpoint: 'https://mail.example/authorize', client_id: 'registered-client', scope: 'openid email', issuer: 'https://mail.example' }; + const REDIRECT = 'http://127.0.0.1:51234/bulwark-desktop/callback'; function authBridge(result: unknown) { - const completeBrowserSignIn = vi.fn(async (_flowId: string, url: string) => ( - typeof result === 'function' ? (result as (u: string) => unknown)(url) : result - )); - const bridge = { - version: 1, - platform: 'win32', - servers: { prepare: vi.fn(async () => ({ ok: true, approved: false })), noteSession: vi.fn() }, - auth: { - beginBrowserSignIn: vi.fn(async () => ({ flowId: 'f1', redirectUri: 'http://127.0.0.1:51234/bulwark-desktop/callback' })), - completeBrowserSignIn, - cancelBrowserSignIn: vi.fn(), - }, - } as unknown as BulwarkDesktopBridge; - setBridge(bridge); - return bridge; + const controls = { + begin: vi.fn(async (): Promise => ({ ok: true, flowId: 'f1', redirectUri: REDIRECT, state: 'main-state' })), + complete: vi.fn(async (_flowId: string, url: string) => ( + typeof result === 'function' ? (result as (u: string) => unknown)(url) : result + )), + useScheme: vi.fn(async () => 'bulwark://sign-in/callback'), + cancel: vi.fn(), + beginPassword: vi.fn(async (): Promise => ({ ok: true, host: 'mail.example' })), + nextPassword: vi.fn(async (): Promise => ({ ok: false, error: 'timeout' })), + answerPassword: vi.fn(), + outcome: vi.fn(async (): Promise => ({ ok: true, code: '', state: 'main-state' })), + }; + setBridge({ version: 1, platform: 'win32', auth: { claim: () => controls } } as unknown as BulwarkDesktopBridge); + return controls; } - const options = { serverUrl: 'https://mail.example', callbackPath: '/en/auth/callback', slot: 1, remember: true, addAccount: false }; + const redeem = vi.fn(async () => true); + const tryPassword = vi.fn(async (): Promise<{ ok: true } | { ok: false; error: 'invalid_credentials' | 'totp_required' | 'other' }> => ({ ok: true })); + const options = { serverUrl: 'https://mail.example', slot: 1, remember: true, addAccount: false, fresh: false, redeem, tryPassword }; beforeEach(() => { + redeem.mockClear(); + tryPassword.mockClear(); vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(ENDPOINTS), { status: 200 }))); }); - it('runs PKCE with the loopback redirect and parks what the callback page needs', async () => { + it('runs PKCE with state from main and the loopback redirect, and redeems here, with nothing in an address', async () => { const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); let authorize = new URL('https://x'); authBridge((url: string) => { authorize = new URL(url); - return { ok: true, code: 'the-code', state: authorize.searchParams.get('state') }; + return { ok: true, code: 'the-code', state: 'main-state' }; + }); + let readBack = {}; + redeem.mockImplementationOnce(async () => { + readBack = { client: sessionStorage.getItem('oauth_client_id'), slot: sessionStorage.getItem('oauth_cookie_slot'), remember: sessionStorage.getItem('oauth_remember') }; + return true; }); const outcome = await runDesktopBrowserSignIn(options); - expect(outcome.kind).toBe('callback'); + expect(outcome).toEqual({ kind: 'signed-in' }); expect(authorize.origin + authorize.pathname).toBe('https://mail.example/authorize'); expect(authorize.searchParams.get('client_id')).toBe('registered-client'); - expect(authorize.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback'); + expect(authorize.searchParams.get('redirect_uri')).toBe(REDIRECT); + expect(authorize.searchParams.get('state')).toBe('main-state'); expect(authorize.searchParams.get('code_challenge_method')).toBe('S256'); expect(authorize.searchParams.get('code_challenge')).toBeTruthy(); - expect(sessionStorage.getItem('oauth_redirect_uri')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback'); - expect(sessionStorage.getItem('oauth_client_id')).toBe('registered-client'); - expect(sessionStorage.getItem('oauth_server_url')).toBe('https://mail.example'); - expect(sessionStorage.getItem('oauth_cookie_slot')).toBe('1'); - expect(sessionStorage.getItem('oauth_code_verifier')).toBeTruthy(); - }); - - it('rejects an answer with another state', async () => { - const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); - authBridge({ ok: true, code: 'c', state: 'forged' }); - const outcome = await runDesktopBrowserSignIn(options); - expect(outcome).toEqual({ kind: 'failed', error: 'state_mismatch' }); + expect(readBack).toEqual({ client: 'registered-client', slot: '1', remember: 'true' }); + const [server, code, verifier, redirect] = redeem.mock.calls[0] as unknown as string[]; + expect([server, code, redirect]).toEqual(['https://mail.example', 'the-code', REDIRECT]); + // The verifier behind the challenge, kept in this function and nowhere else. + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(authorize.toString()).not.toContain(verifier); expect(sessionStorage.getItem('oauth_code_verifier')).toBeNull(); + expect(sessionStorage.getItem('oauth_client_id')).toBeNull(); }); - it('sends nothing to a server the user declined', async () => { + it('refuses an answer another issuer signed', async () => { const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); - const bridge = authBridge({ ok: false, error: 'cancelled' }); - bridge.servers!.prepare = vi.fn(async () => ({ ok: false as const, reason: 'declined' as const })); - const outcome = await runDesktopBrowserSignIn(options); - expect(outcome.kind).toBe('declined'); - expect(bridge.auth!.beginBrowserSignIn).not.toHaveBeenCalled(); + authBridge({ ok: true, code: 'c', state: 'main-state', iss: 'https://evil.example' }); + expect(await runDesktopBrowserSignIn(options)).toEqual({ kind: 'failed', error: 'issuer_mismatch' }); + expect(redeem).not.toHaveBeenCalled(); + }); + + it('asks the provider for a new login when the app says one is due', async () => { + const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); + let authorize = new URL('https://x'); + authBridge((url: string) => { + authorize = new URL(url); + return { ok: false, error: 'cancelled' }; + }); + await runDesktopBrowserSignIn({ ...options, fresh: true }); + expect(authorize.searchParams.get('prompt')).toBe('login'); + }); + + it.each(['declined', 'policy', 'invalid'])('takes the no from main: %s, and nothing is asked of the server', async (error) => { + const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); + const controls = authBridge({ ok: false, error: 'cancelled' }); + controls.begin.mockResolvedValueOnce({ ok: false, error }); + expect((await runDesktopBrowserSignIn(options)).kind).toBe(error); + expect(controls.complete).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled(); }); - it('says so when the server has no OAuth, and closes the listener', async () => { + it('a timeout is its own answer, so the page can say try again', async () => { const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); - vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 }))); - const bridge = authBridge({ ok: true }); - const outcome = await runDesktopBrowserSignIn(options); - expect(outcome.kind).toBe('unavailable'); - expect(bridge.auth!.cancelBrowserSignIn).toHaveBeenCalledWith('f1'); - expect(bridge.auth!.completeBrowserSignIn).not.toHaveBeenCalled(); + authBridge({ ok: false, error: 'timeout' }); + expect((await runDesktopBrowserSignIn(options)).kind).toBe('timeout'); + }); + + it('a discovery outage is a failure, not the password page', async () => { + const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); + vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 503 }))); + const controls = authBridge({ ok: true }); + expect((await runDesktopBrowserSignIn(options)).kind).toBe('failed'); + expect(controls.cancel).toHaveBeenCalledWith('f1'); + expect(controls.beginPassword).not.toHaveBeenCalled(); + }); + + it('a server with no OAuth gets the page main serves, and each post goes through the login of the app', async () => { + const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: 'no_oauth' }), { status: 404 }))); + const controls = authBridge({ ok: true }); + controls.nextPassword + .mockResolvedValueOnce({ ok: true, username: 'a', password: 'wrong' }) + .mockResolvedValueOnce({ ok: true, username: 'a', password: 'right', totp: '123456' }); + tryPassword.mockResolvedValueOnce({ ok: false, error: 'invalid_credentials' }).mockResolvedValueOnce({ ok: true }); + expect(await runDesktopBrowserSignIn(options)).toEqual({ kind: 'signed-in' }); + expect(controls.complete).not.toHaveBeenCalled(); + expect(tryPassword.mock.calls.map((call) => (call as unknown[])[0])).toEqual([ + { ok: true, username: 'a', password: 'wrong' }, + { ok: true, username: 'a', password: 'right', totp: '123456' }, + ]); + expect(controls.answerPassword.mock.calls).toEqual([ + ['f1', { ok: false, error: 'invalid_credentials' }], + ['f1', { ok: true }], + ]); + }); + + it('a server that refuses loopback goes by the scheme', async () => { + const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ...ENDPOINTS, use_scheme: true }), { status: 200 }))); + let authorize = new URL('https://x'); + const controls = authBridge((url: string) => { + authorize = new URL(url); + return { ok: true, code: 'c', state: 'main-state' }; + }); + await runDesktopBrowserSignIn(options); + expect(controls.useScheme).toHaveBeenCalledWith('f1'); + expect(authorize.searchParams.get('redirect_uri')).toBe('bulwark://sign-in/callback'); + expect((redeem.mock.calls[0] as unknown as string[])[3]).toBe('bulwark://sign-in/callback'); + }); +}); + +describe('runInstanceBrowserSignIn', () => { + const REDIRECT = 'http://127.0.0.1:51234/bulwark-desktop/callback'; + + function bridgeWith(complete: (flowId: string, url: string) => unknown) { + const controls = { + begin: vi.fn(async () => ({ ok: true, flowId: 'f1', redirectUri: REDIRECT, state: 'main-state' })), + complete: vi.fn(async (flowId: string, url: string) => complete(flowId, url)), + cancel: vi.fn(), + }; + setBridge({ version: 1, platform: 'win32', auth: { claim: () => controls } } as unknown as BulwarkDesktopBridge); + return controls; + } + + it('opens the login page of the instance with no secret in the address, and hands back the verifier', async () => { + const { runInstanceBrowserSignIn } = await import('../browser-sign-in'); + let opened = new URL('https://x'); + bridgeWith((_flow, url) => { + opened = new URL(url); + return { ok: true, code: 'the-code', state: 'main-state' }; + }); + const outcome = await runInstanceBrowserSignIn({ base: 'https://bulwark.example/mail', locale: 'de', fresh: false }); + expect(opened.origin + opened.pathname).toBe('https://bulwark.example/mail/de/login'); + expect(opened.searchParams.get('mobile_redirect_uri')).toBe(REDIRECT); + expect(opened.searchParams.get('mobile_state')).toBe('main-state'); + expect(opened.searchParams.get('mobile_code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(opened.searchParams.has('fresh_login')).toBe(false); + if (outcome.kind !== 'code') throw new Error(outcome.kind); + expect(outcome.code).toBe('the-code'); + expect(opened.toString()).not.toContain(outcome.verifier); + const challenge = Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(outcome.verifier))).toString('base64url'); + expect(opened.searchParams.get('mobile_code_challenge')).toBe(challenge); + }); + + it('passes on that a new login is due, and takes a timeout as a timeout', async () => { + const { runInstanceBrowserSignIn } = await import('../browser-sign-in'); + let opened = new URL('https://x'); + bridgeWith((_flow, url) => { + opened = new URL(url); + return { ok: false, error: 'timeout' }; + }); + expect((await runInstanceBrowserSignIn({ base: 'https://b.example', locale: 'en', fresh: true })).kind).toBe('timeout'); + expect(opened.searchParams.get('fresh_login')).toBe('1'); + }); +}); + +describe('who gets the sign-in calls', () => { + it('claims them once per bridge, whatever asks after that', async () => { + const { claimBrowserSignIn } = await import('../browser-sign-in'); + const controls = {}; + const claim = vi.fn(() => controls); + setBridge({ version: 1, platform: 'win32', auth: { claim } } as unknown as BulwarkDesktopBridge); + expect(claimBrowserSignIn()).toBe(controls); + expect(claimBrowserSignIn()).toBe(controls); + expect(claim).toHaveBeenCalledTimes(1); + }); + + it('offers nothing in a browser, under an older shell, or with the policy off', async () => { + const { browserSignInAvailable } = await import('../browser-sign-in'); + setBridge(undefined); + expect(browserSignInAvailable()).toBe(false); + setBridge({ version: 1, platform: 'win32' } as unknown as BulwarkDesktopBridge); + expect(browserSignInAvailable()).toBe(false); + const claim = () => ({}); + setBridge({ version: 1, platform: 'win32', auth: { claim } } as unknown as BulwarkDesktopBridge); + expect(browserSignInAvailable()).toBe(true); + const { EMPTY_POLICY_LAYERS, resolvePolicies } = await import('../policy-schema'); + const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { BrowserSignIn: false } }); + setBridge({ version: 1, platform: 'win32', auth: { claim }, policy: { get: () => snapshot, onChange: () => () => {} } } as unknown as BulwarkDesktopBridge); + expect(browserSignInAvailable()).toBe(false); }); }); diff --git a/lib/desktop/__tests__/desktop-oauth-route.test.ts b/lib/desktop/__tests__/desktop-oauth-route.test.ts new file mode 100644 index 000000000..739529970 --- /dev/null +++ b/lib/desktop/__tests__/desktop-oauth-route.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +/** + * What the desktop app asks before it sends someone to a server's own sign-in page: the + * authorize endpoint and a client id, and which redirect that server will take. + */ + +vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } })); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: async () => {}, get: (key: string, fallback: unknown) => (key === 'allowCustomJmapEndpoint' ? true : fallback) }, +})); +vi.mock('@/lib/oauth/custom-upstream', () => ({ + resolveCustomUpstream: async (serverUrl: string, clientId: string) => (serverUrl.startsWith('https://') ? { serverUrl, clientId, trusted: false } : null), +})); +const discoverOAuth = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/oauth/discovery', () => ({ discoverOAuth })); +vi.mock('@/lib/oauth/token-exchange', () => ({ getDiscoveryValidator: () => undefined })); + +const SERVER = 'https://mail.example'; +const REDIRECT = 'http://127.0.0.1:51234/bulwark-desktop/callback'; +const METADATA = { issuer: SERVER, authorization_endpoint: `${SERVER}/login`, token_endpoint: `${SERVER}/auth/token` }; + +let registrations: { url: string; redirect: string }[]; +let answer: (redirect: string) => Response; + +function post(body: unknown) { + return new NextRequest(`https://app.bulwark.localhost/api/auth/desktop-oauth`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' }, + body: JSON.stringify(body), + }); +} + +async function route() { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + vi.resetModules(); + return (await import('@/app/api/auth/desktop-oauth/route')).POST; +} + +beforeEach(() => { + registrations = []; + discoverOAuth.mockReset().mockResolvedValue(METADATA); + answer = () => new Response(JSON.stringify({ client_id: 'swc1.abc' }), { status: 201 }); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const redirect = (JSON.parse(String(init?.body)) as { redirect_uris: string[] }).redirect_uris[0]; + registrations.push({ url: String(input), redirect }); + return answer(redirect); + })); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('/api/auth/desktop-oauth', () => { + it('registers the loopback redirect, with no port: servers ignore the port', async () => { + const POST = await route(); + const res = await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT })); + expect(await res.json()).toMatchObject({ authorization_endpoint: `${SERVER}/login`, client_id: 'swc1.abc', issuer: SERVER }); + expect(registrations).toEqual([{ url: `${SERVER}/auth/register`, redirect: 'http://127.0.0.1/bulwark-desktop/callback' }]); + }); + + it('uses the registration endpoint the server names', async () => { + discoverOAuth.mockResolvedValue({ ...METADATA, registration_endpoint: `${SERVER}/oauth/clients` }); + const POST = await route(); + await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT })); + expect(registrations[0].url).toBe(`${SERVER}/oauth/clients`); + }); + + it('says so when a server has no OAuth', async () => { + discoverOAuth.mockResolvedValue(null); + const POST = await route(); + const res = await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT })); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'no_oauth' }); + }); + + it('a discovery outage is not the same answer', async () => { + discoverOAuth.mockRejectedValue(new Error('down')); + const POST = await route(); + expect((await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }))).status).toBe(503); + }); + + it('with registration off, sends the fixed id and the loopback redirect', async () => { + answer = () => new Response('{}', { status: 401 }); + const POST = await route(); + const body = await (await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }))).json(); + expect(body.client_id).toBe('bulwark-desktop'); + expect(body.use_scheme).toBeUndefined(); + expect(registrations).toHaveLength(1); + }); + + it('takes the scheme only from a server that refuses loopback by name and accepts the scheme', async () => { + answer = (redirect) => (redirect.startsWith('bulwark:') + ? new Response(JSON.stringify({ client_id: 'schemed' }), { status: 201 }) + : new Response(JSON.stringify({ error: 'invalid_redirect_uri' }), { status: 400 })); + const POST = await route(); + const body = await (await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }))).json(); + expect(body).toMatchObject({ client_id: 'schemed', use_scheme: true }); + expect(registrations.map((r) => r.redirect)).toEqual(['http://127.0.0.1/bulwark-desktop/callback', 'bulwark://sign-in/callback']); + }); + + it('stays on loopback when the server refuses both', async () => { + answer = () => new Response(JSON.stringify({ error: 'invalid_redirect_uri' }), { status: 400 }); + const POST = await route(); + const body = await (await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }))).json(); + expect(body.client_id).toBe('bulwark-desktop'); + expect(body.use_scheme).toBeUndefined(); + }); + + it.each(['http://localhost:51234/bulwark-desktop/callback', 'https://evil.example/bulwark-desktop/callback', 'bulwark://sign-in/callback'])('refuses %s as the redirect', async (redirectUri) => { + const POST = await route(); + expect((await POST(post({ serverUrl: SERVER, redirectUri }))).status).toBe(400); + expect(registrations).toEqual([]); + }); +}); diff --git a/lib/desktop/__tests__/edit-attachments.test.ts b/lib/desktop/__tests__/edit-attachments.test.ts new file mode 100644 index 000000000..371bdb053 --- /dev/null +++ b/lib/desktop/__tests__/edit-attachments.test.ts @@ -0,0 +1,279 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const policy = vi.hoisted(() => ({ decided: true, download: true, export: true })); + +vi.mock('@/lib/device-policy', () => ({ + canDownload: () => policy.download, + canExport: () => policy.export, +})); +vi.mock('@/lib/offline/offline-mode', () => ({ + scopeDecided: () => policy.decided, +})); + +import { + canEditFile, + editAllowed, + editFileInfo, + editForbidden, + followEditSessions, + parkFilesForReply, + parseEditSource, + readEditedFile, + resetEditSessions, + startAttachmentEdit, + takeFilesForReply, + useEditSessions, + type StartEditInput, +} from '../edit-attachments'; +import { setAppLockCheck } from '../app-lock-state'; +import { runWipeCleaners } from '@/lib/offline/wipe-registry'; +import { runBeforeSignOut } from '@/lib/sign-out-cleanup'; + +function fakeBridge() { + return { + canEdit: vi.fn((name: string) => !name.endsWith('.exe')), + begin: vi.fn(async (_key: string) => ({ ok: true as const, ticket: 't1' } as { ok: true; ticket: string; open?: string } | { ok: false; reason: 'locked' })), + open: vi.fn(async () => ({ ok: true as const, id: 's1' })), + reopen: vi.fn(async () => ({ ok: true as const, id: 's1' })), + list: vi.fn(async () => []), + settle: vi.fn(async () => []), + read: vi.fn(async () => ({ ok: true as const, name: 'a.docx', type: 'x/y', data: new Uint8Array([1, 2]), hash: 'h' })), + taken: vi.fn(async () => {}), + release: vi.fn(async () => {}), + discard: vi.fn(async () => ({ held: [] as string[] })), + forceDiscard: vi.fn(async () => ({ held: [] as string[] })), + askClear: vi.fn(async () => true), + clear: vi.fn(async () => ({ held: [] as string[] })), + wipe: vi.fn(async () => ({ held: [] as string[] })), + shown: vi.fn(), + setSource: vi.fn(), + claim: vi.fn(), + onChange: vi.fn(() => () => {}), + }; +} + +let bridge: ReturnType; + +const input = (over: Partial = {}): StartEditInput => ({ + key: 'k', + name: 'a.docx', + type: 'x/y', + account: 'acct', + source: { kind: 'mail', emailId: 'e1' }, + load: vi.fn(async () => new Uint8Array([9]).buffer), + ...over, +}); + +beforeEach(() => { + policy.decided = true; + policy.download = true; + policy.export = true; + bridge = fakeBridge(); + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, edit: bridge }; + resetEditSessions(); + setAppLockCheck(null); +}); + +afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('the policy', () => { + it('takes both saving attachments and export', () => { + expect(editAllowed('acct')).toBe(true); + policy.download = false; + expect(editAllowed('acct')).toBe(false); + policy.download = true; + policy.export = false; + expect(editAllowed('acct')).toBe(false); + }); + + it('counts an unknown policy as no for starting, and as no reason to delete anything', () => { + policy.decided = false; + expect(editAllowed('acct')).toBe(false); + expect(editForbidden('acct')).toBe(false); + policy.decided = true; + policy.export = false; + expect(editForbidden('acct')).toBe(true); + }); +}); + +describe('starting', () => { + it('asks main from the click, then fetches, then opens with what it fetched', async () => { + const order: string[] = []; + bridge.begin.mockImplementation(async () => { + order.push('begin'); + return { ok: true, ticket: 't1' }; + }); + const start = input({ load: async () => { + order.push('load'); + return new Uint8Array([7, 8]).buffer; + } }); + bridge.open.mockImplementation(async () => { + order.push('open'); + return { ok: true, id: 's1' }; + }); + expect(await startAttachmentEdit(start)).toEqual({ ok: true, id: 's1' }); + expect(order).toEqual(['begin', 'load', 'open']); + const [ticket, file] = bridge.open.mock.calls[0] as unknown as [string, { data: Uint8Array; source: string; account: string; key: string }]; + expect(ticket).toBe('t1'); + expect([...file.data]).toEqual([7, 8]); + expect(file).toMatchObject({ account: 'acct', key: 'k' }); + expect(JSON.parse(file.source)).toEqual({ kind: 'mail', emailId: 'e1' }); + }); + + it('touches nothing when the organisation says no', async () => { + policy.download = false; + const start = input(); + expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'policy' }); + expect(bridge.begin).not.toHaveBeenCalled(); + expect(start.load).not.toHaveBeenCalled(); + }); + + it('stops when the answer changes while the bytes are on their way, as a guarded sink does', async () => { + const start = input({ load: async () => { + policy.export = false; + return new Uint8Array([1]).buffer; + } }); + expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'policy' }); + expect(bridge.open).not.toHaveBeenCalled(); + }); + + it('stops when the app locks while the bytes are on their way', async () => { + const start = input({ load: async () => { + setAppLockCheck(() => true); + return new Uint8Array([1]).buffer; + } }); + expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'locked' }); + expect(bridge.open).not.toHaveBeenCalled(); + }); + + it('opens the same file again and fetches nothing, so the edits stay', async () => { + bridge.begin.mockResolvedValue({ ok: true, ticket: 't2', open: 's9' }); + const start = input(); + await startAttachmentEdit(start); + expect(bridge.reopen).toHaveBeenCalledWith('t2', 's9'); + expect(start.load).not.toHaveBeenCalled(); + expect(bridge.open).not.toHaveBeenCalled(); + }); + + it('passes on what main refuses', async () => { + bridge.begin.mockResolvedValue({ ok: false, reason: 'locked' }); + const start = input(); + expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'locked' }); + expect(start.load).not.toHaveBeenCalled(); + }); + + it('asks the shell which files may go, and answers no without a shell', () => { + expect(canEditFile('a.docx')).toBe(true); + expect(canEditFile('a.exe')).toBe(false); + expect(canEditFile(null)).toBe(false); + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + expect(canEditFile('a.docx')).toBe(false); + }); + + it('does nothing in a browser', async () => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + const start = input(); + expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'unavailable' }); + expect(start.load).not.toHaveBeenCalled(); + }); +}); + +describe('reading a saved file back', () => { + it('refuses when the organisation has said no since', async () => { + policy.export = false; + expect(await readEditedFile({ id: 's1', account: 'acct' })).toEqual({ ok: false, reason: 'policy' }); + expect(bridge.read).not.toHaveBeenCalled(); + }); + + it('gives a file that remembers its session and version', async () => { + const outcome = await readEditedFile({ id: 's1', account: 'acct' }); + if (!outcome.ok) throw new Error('refused'); + expect(outcome.file.name).toBe('a.docx'); + expect(outcome.file.type).toBe('x/y'); + expect(new Uint8Array(await outcome.file.arrayBuffer())).toEqual(new Uint8Array([1, 2])); + expect(editFileInfo(outcome.file)).toEqual({ id: 's1', hash: 'h' }); + expect(editFileInfo(new File([], 'other'))).toBeUndefined(); + }); + + it('passes a held or lost file on as such', async () => { + bridge.read.mockResolvedValueOnce({ ok: false, reason: 'busy' } as never); + expect(await readEditedFile({ id: 's1', account: 'acct' })).toEqual({ ok: false, reason: 'busy' }); + }); +}); + +describe('a file on its way to a reply', () => { + it('waits for that message, once', () => { + const file = new File([], 'a.txt'); + parkFilesForReply('e1', [file]); + expect(takeFilesForReply('e2')).toEqual([]); + expect(takeFilesForReply('e1')).toEqual([file]); + expect(takeFilesForReply('e1')).toEqual([]); + }); + + it('does not wait for ever', () => { + vi.useFakeTimers(); + try { + parkFilesForReply('e1', [new File([], 'a.txt')]); + vi.advanceTimersByTime(3 * 60 * 1000); + expect(takeFilesForReply('e1')).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('what main says', () => { + it('follows the list once per page', async () => { + const session = { id: 's1', name: 'a', type: '', account: 'x', source: '', changed: true, version: 1, hash: 'h', claimed: false }; + bridge.list.mockResolvedValue([session] as never); + followEditSessions(); + followEditSessions(); + expect(bridge.onChange).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(useEditSessions.getState().sessions).toEqual([session])); + (bridge.onChange.mock.calls[0] as unknown as [(s: unknown[]) => void])[0]([]); + expect(useEditSessions.getState().sessions).toEqual([]); + }); +}); + +describe('where a source came from', () => { + it('reads what it wrote, and treats anything else as just a file', () => { + expect(parseEditSource(JSON.stringify({ kind: 'mail', emailId: 'e', ownerAccountId: 'o' }))).toEqual({ kind: 'mail', emailId: 'e', ownerAccountId: 'o' }); + expect(parseEditSource(JSON.stringify({ kind: 'draft', draftId: 'd' }))).toEqual({ kind: 'draft', draftId: 'd' }); + expect(parseEditSource(JSON.stringify({ kind: 'draft', draftId: null }))).toEqual({ kind: 'draft', draftId: null }); + expect(parseEditSource(JSON.stringify({ kind: 'mail' }))).toEqual({ kind: 'file' }); + expect(parseEditSource('not json')).toEqual({ kind: 'file' }); + }); +}); + +describe('the copies go with the account', () => { + it('on a wipe of an account, and of everything, with no question', async () => { + await expect(runWipeCleaners('acct')).resolves.toBe(true); + expect(bridge.wipe).toHaveBeenCalledWith('acct'); + bridge.wipe.mockClear(); + await runWipeCleaners(null); + expect(bridge.wipe).toHaveBeenCalledWith(null); + expect(bridge.clear).not.toHaveBeenCalled(); + }); + + it('a wipe with a copy a program holds does not count as done', async () => { + bridge.wipe.mockResolvedValueOnce({ held: ['a.docx'] }); + await expect(runWipeCleaners('acct')).resolves.toBe(false); + }); + + it('at sign-out', async () => { + await runBeforeSignOut('acct'); + expect(bridge.clear).toHaveBeenCalledWith('acct'); + }); + + it("not when the app ended the session itself and keeps the account's data", async () => { + await runBeforeSignOut('acct', { keepLocalStore: true }); + expect(bridge.clear).not.toHaveBeenCalled(); + }); + + it('a cleaner with no shell is no failure', async () => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + await expect(runWipeCleaners(null)).resolves.toBe(true); + }); +}); diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts index d3f717f64..35338c572 100644 --- a/lib/desktop/__tests__/os-commands.test.ts +++ b/lib/desktop/__tests__/os-commands.test.ts @@ -228,6 +228,17 @@ describe('a toast\'s buttons', () => { stop(); }); + it('a toast for a saved attachment only brings the window up, and leaves the page where it is', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'notification', action: 'open', context: JSON.stringify({ editedFile: true }) }); + expect(focus).toHaveBeenCalled(); + expect(opener).not.toHaveBeenCalled(); + expect(leaveFor).not.toHaveBeenCalled(); + stop(); + }); + it('a click on a summary toast opens the Inbox of that login', async () => { const { setMailLinkOpener } = await import('../mail-links'); const opener = vi.fn(); diff --git a/lib/desktop/__tests__/policy-schema.test.ts b/lib/desktop/__tests__/policy-schema.test.ts index 3411ad9ea..96ca32511 100644 --- a/lib/desktop/__tests__/policy-schema.test.ts +++ b/lib/desktop/__tests__/policy-schema.test.ts @@ -30,7 +30,7 @@ describe('the schema', () => { 'InstanceUrl', 'AllowStandalone', 'AllowedJmapServers', 'AllowInsecureInstance', 'UpdateChannel', 'AutoUpdate', 'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed', 'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist', - 'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'WipeOfflineData', + 'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'BrowserSignIn', 'WipeOfflineData', ]); for (const key of POLICY_KEYS) { const { title, description } = POLICY_SCHEMA[key]; diff --git a/lib/desktop/__tests__/server-redirect.test.ts b/lib/desktop/__tests__/server-redirect.test.ts new file mode 100644 index 000000000..8442de02b --- /dev/null +++ b/lib/desktop/__tests__/server-redirect.test.ts @@ -0,0 +1,200 @@ +// @vitest-environment node +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * An approved server sits inside the SSRF guard, so where it redirects to is the + * whole question. Real sockets: what counts is who received the request, and + * whether it carried the user's credentials. + */ + +const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins'); +const POLICY_KEY = Symbol.for('bulwark.desktop.serverPolicy'); +const holder = globalThis as unknown as Record; +const AUTH = `Basic ${Buffer.from('probe@example.org:not-a-real-password').toString('base64')}`; + +interface Host { + origin: string; + hits: { url: string; auth: boolean }[]; + answer: (req: IncomingMessage, res: ServerResponse) => void; +} + +const servers: Server[] = []; + +function session(res: ServerResponse) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ apiUrl: '/jmap/', accounts: { a: {} }, primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, username: 'probe@example.org' })); +} + +async function host(answer: Host['answer'] = (_req, res) => session(res)): Promise { + const self: Host = { origin: '', hits: [], answer }; + const server = createServer((req, res) => { + self.hits.push({ url: req.url ?? '', auth: !!req.headers.authorization }); + self.answer(req, res); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + self.origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + return self; +} + +const redirectTo = (location: string, status = 307): Host['answer'] => (_req, res) => { + res.writeHead(status, { location }); + res.end(); +}; + +function approve(...origins: string[]) { + holder[TRUST_KEY] = new Set(origins); +} + +beforeEach(() => { + vi.resetModules(); + delete holder[TRUST_KEY]; + delete holder[POLICY_KEY]; +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + delete holder[TRUST_KEY]; + delete holder[POLICY_KEY]; + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve)))); +}); + +async function verify(server: string) { + const { fetchJmapSessionIdentity } = await import('@/lib/auth/verify-jmap-auth'); + return fetchJmapSessionIdentity(server, AUTH, { trusted: true }); +} + +async function fetchVia(url: string, init: RequestInit = {}) { + const { fetchJmapServer } = await import('@/lib/stalwart/server-fetch'); + return fetchJmapServer(url, { headers: { Authorization: AUTH }, ...init }, true); +} + +describe('the desktop build, signing in to an approved server', () => { + beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1')); + + it('follows a redirect on the same origin, as Stalwart answers /.well-known/jmap', async () => { + const a = await host((req, res) => (req.url === '/.well-known/jmap' ? redirectTo('/jmap/session')(req, res) : session(res))); + approve(a.origin); + await expect(verify(a.origin)).resolves.toBeTruthy(); + expect(a.hits).toEqual([{ url: '/.well-known/jmap', auth: true }, { url: '/jmap/session', auth: true }]); + }); + + it('refuses a redirect to a server nobody approved, which gets nothing', async () => { + const b = await host(); + const a = await host(redirectTo('')); + a.answer = redirectTo(`${b.origin}/.well-known/jmap`); + approve(a.origin); + await expect(verify(a.origin)).rejects.toMatchObject({ name: 'JmapAuthVerificationError', status: 502 }); + expect(b.hits).toEqual([]); + }); + + it('counts another name for the same machine as another server', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin.replace('127.0.0.1', 'localhost')}/x`)); + approve(a.origin); + await expect(verify(a.origin)).rejects.toMatchObject({ status: 502 }); + expect(b.hits).toEqual([]); + }); + + it('follows one to another approved server', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin}/jmap/session`)); + approve(a.origin, b.origin); + await expect(verify(a.origin)).resolves.toBeTruthy(); + expect(b.hits).toEqual([{ url: '/jmap/session', auth: true }]); + }); + + it('holds AllowedJmapServers across the redirect, even to an approved server', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin}/jmap/session`)); + approve(a.origin, b.origin); + holder[POLICY_KEY] = { pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: [a.origin] }; + await expect(verify(a.origin)).rejects.toMatchObject({ status: 502 }); + expect(b.hits).toEqual([]); + }); +}); + +describe('the desktop build, other requests to an approved server', () => { + beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1')); + + it('follows the same origin', async () => { + const a = await host((req, res) => (req.url === '/.well-known/jmap' ? redirectTo('/jmap/session')(req, res) : session(res))); + approve(a.origin); + expect((await fetchVia(`${a.origin}/.well-known/jmap`)).status).toBe(200); + expect(a.hits.map((hit) => hit.auth)).toEqual([true, true]); + }); + + it('refuses a server nobody approved, which gets nothing', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin}/x`)); + approve(a.origin); + await expect(fetchVia(`${a.origin}/y`)).rejects.toThrow(); + expect(b.hits).toEqual([]); + }); + + it('still hands back the redirect when asked to', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin}/x`)); + approve(a.origin); + expect((await fetchVia(`${a.origin}/y`, { redirect: 'manual' })).status).toBe(307); + expect(b.hits).toEqual([]); + }); + + it('postJmap keeps to the approved set too', async () => { + const b = await host(); + const a = await host(redirectTo(`https://127.0.0.1:${new URL(b.origin).port}/jmap/`)); + approve(a.origin); + const { postJmap } = await import('@/lib/stalwart/jmap-api'); + await expect(postJmap(`${a.origin}/jmap`, AUTH, '{}', { trusted: true })).rejects.toThrow(/untrusted host/); + expect(b.hits).toEqual([]); + }); +}); + +describe('which hops count', () => { + beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1')); + + async function refused(from: string, to: string, approved = true) { + const { desktopRedirectRefused } = await import('../server-redirect'); + return desktopRedirectRefused(from, to, approved); + } + + it('lets the same host move up to https on the standard ports only', async () => { + expect(await refused('http://mail.lan/x', 'https://mail.lan/x')).toBe(false); + expect(await refused('http://mail.lan:8080/x', 'https://mail.lan/x')).toBe(true); + expect(await refused('http://mail.lan/x', 'https://mail.lan:8443/x')).toBe(true); + expect(await refused('http://mail.lan/x', 'https://other.lan/x')).toBe(true); + expect(await refused('https://mail.lan/x', 'http://mail.lan/x')).toBe(true); + }); + + it('leaves a public server to the hop-by-hop guard, but not to the policy', async () => { + expect(await refused('https://a.example/x', 'https://b.example/x', false)).toBe(false); + holder[POLICY_KEY] = { pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: ['https://a.example'] }; + expect(await refused('https://a.example/x', 'https://b.example/x', false)).toBe(true); + expect(await refused('https://a.example/x', 'https://a.example/y', false)).toBe(false); + }); +}); + +describe('a server install', () => { + it('follows an admin-configured server as it always did', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin}/jmap/session`)); + approve(a.origin); + await expect(verify(a.origin)).resolves.toBeTruthy(); + expect(b.hits).toEqual([{ url: '/jmap/session', auth: true }]); + }); + + it('other requests too, where fetch takes the credentials off', async () => { + const b = await host(); + const a = await host(redirectTo(`${b.origin}/x`)); + expect((await fetchVia(`${a.origin}/y`)).status).toBe(200); + expect(b.hits).toEqual([{ url: '/x', auth: false }]); + }); + + it('has no opinion on any hop', async () => { + const { desktopRedirectRefused } = await import('../server-redirect'); + approve(); + holder[POLICY_KEY] = { pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: [] }; + expect(desktopRedirectRefused('http://a.lan/', 'http://169.254.169.254/', true)).toBe(false); + }); +}); diff --git a/lib/desktop/__tests__/server-secrets.test.ts b/lib/desktop/__tests__/server-secrets.test.ts new file mode 100644 index 000000000..ff7016879 --- /dev/null +++ b/lib/desktop/__tests__/server-secrets.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * The desktop app's server gets its secrets by message, so they stay out of its + * environment block. No other build looks there. + */ + +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { get: (_key: string, fallback: unknown) => fallback }, +})); +vi.mock('@/lib/shared-store', () => ({ isSharedMode: () => false, getStore: () => ({}) })); +vi.mock('@/lib/admin/paths', () => ({ ensureStateDir: async () => {}, getStatePath: () => 'C:/nowhere/.session-secret' })); + +const KEY = Symbol.for('bulwark.desktop.serverSecrets'); +const holder = globalThis as unknown as Record; +const FROM_SHELL = 'from-the-shell-from-the-shell-from-the-shell'; + +async function sessionSecret() { + vi.resetModules(); + return (await import('@/lib/auth/session-secret')).getSessionSecret(); +} + +beforeEach(() => { + holder[KEY] = { sessionSecret: FROM_SHELL, adminPassword: 'admin-from-the-shell' }; +}); + +afterEach(() => { + vi.unstubAllEnvs(); + delete holder[KEY]; +}); + +describe('the session secret', () => { + it('comes from the shell in the desktop build, with nothing in the environment', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + vi.stubEnv('SESSION_SECRET', ''); + expect(await sessionSecret()).toBe(FROM_SHELL); + }); + + it('never from there in a server build', async () => { + vi.stubEnv('SESSION_SECRET', ''); + expect(await sessionSecret()).toBe(''); + vi.stubEnv('SESSION_SECRET', 'from-the-environment'); + expect(await sessionSecret()).toBe('from-the-environment'); + }); +}); + +describe('the admin password', () => { + it('is only readable from the shell in the desktop build', async () => { + const { desktopServerSecret } = await import('../server-secrets'); + expect(desktopServerSecret('adminPassword')).toBe(''); + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + expect(desktopServerSecret('adminPassword')).toBe('admin-from-the-shell'); + delete holder[KEY]; + expect(desktopServerSecret('adminPassword')).toBe(''); + }); +}); diff --git a/lib/desktop/__tests__/trust-flag.test.ts b/lib/desktop/__tests__/trust-flag.test.ts index c40bd5846..ad56705eb 100644 --- a/lib/desktop/__tests__/trust-flag.test.ts +++ b/lib/desktop/__tests__/trust-flag.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { build } from 'esbuild'; -import { desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs'; +import { DESKTOP_TRUST_MARKER, desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs'; const ROOT = path.resolve(__dirname, '../../..'); const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins'); @@ -112,6 +112,42 @@ describe('the flag is fixed at build time', () => { } }); + /** Compiles the trust check alone, kept as an export the way Turbopack keeps it. */ + async function trustCheckOfBuild(flag: string): Promise { + const result = await build({ + entryPoints: [path.join(ROOT, 'lib/desktop/trusted-servers.ts')], + bundle: true, + write: false, + minify: true, + format: 'esm', + platform: 'node', + define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(flag) }, + }); + return result.outputFiles[0].text; + } + + it('a server build holds no trust path, even when a bundler keeps the unused export', async () => { + expect(await trustCheckOfBuild('')).not.toContain(DESKTOP_TRUST_MARKER); + }); + + it('a desktop build still holds it', async () => { + expect(await trustCheckOfBuild('1')).toContain(DESKTOP_TRUST_MARKER); + }); + + it('the secrets from the shell are read in the desktop build only', async () => { + const compile = async (flag: string) => (await build({ + entryPoints: [path.join(ROOT, 'lib/desktop/server-secrets.ts')], + bundle: true, + write: false, + minify: true, + format: 'esm', + platform: 'node', + define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(flag) }, + })).outputFiles[0].text; + expect(await compile('')).not.toContain('bulwark.desktop.serverSecrets'); + expect(await compile('1')).toContain('bulwark.desktop.serverSecrets'); + }); + it('no image build sets it', () => { for (const file of ['Dockerfile', 'Dockerfile.lite']) { expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP'); diff --git a/lib/desktop/blocked-routes.ts b/lib/desktop/blocked-routes.ts index 093b38cf0..ec849e317 100644 --- a/lib/desktop/blocked-routes.ts +++ b/lib/desktop/blocked-routes.ts @@ -3,7 +3,7 @@ * hand-off both hand out `https://app.bulwark.localhost`, which nothing else * can reach, and the app has no use for impersonation. proxy.ts answers 404. */ -const BLOCKED = /^\/api\/auth\/(pair|handoff|impersonate)(\/|$)/; +const BLOCKED = /^\/api\/auth\/(pair|handoff|desktop-handoff|impersonate)(\/|$)/; export function isDesktopBlockedRoute(pathname: string): boolean { return BLOCKED.test(pathname); diff --git a/lib/desktop/browser-sign-in.ts b/lib/desktop/browser-sign-in.ts index a48437771..6bc5b1fda 100644 --- a/lib/desktop/browser-sign-in.ts +++ b/lib/desktop/browser-sign-in.ts @@ -1,104 +1,210 @@ /** - * Sign-in through the system browser in the desktop build, for any Stalwart. + * "Sign in with your browser" in the desktop app, so a password manager, passkeys and an + * organisation's sign-in all work where they live. * - * A window on app.bulwark.localhost can't be a redirect target: Stalwart 1.0 - * refuses it for a public client, and identity providers won't embed. So the - * Electron main process listens on http://127.0.0.1: for one redirect and - * hands the code back. The rest is the regular PKCE flow: the same - * sessionStorage keys, then the callback page, which reads the stored redirect - * URI because it can't be rebuilt from this page's origin. + * Standalone: RFC 8252. The mail server's own authorize page in the system browser, with + * the redirect going to a listener in the Electron main process, and this page redeeming + * the code with the PKCE verifier it kept. A server with no OAuth at all gets a small page + * main serves on a stable loopback port, and what's posted there comes down the app's + * normal login path here. * - * Google and Entra can't work here. Their clients are registered by whoever - * owns them, and in standalone mode nobody does. Those need an instance. + * Instance: the instance's real login page at its real origin, so saved passwords match. + * It parks the sign-in on the instance and sends a one-time code to the same kind of + * listener (lib/auth/mobile-handoff.ts). The page redeems it with the verifier. + * + * The calls are taken once per document, in the first render (claimBrowserSignIn, from + * WindowControlsHost): a same-origin frame can reach the bridge, and main can't tell. + * + * Google and Entra can't work in standalone. Their clients are registered by whoever owns + * them, and there nobody does. Those need an instance. */ import { apiFetch } from '@/lib/browser-navigation'; -import { generateCodeChallenge, generateCodeVerifier, generateState } from '@/lib/oauth/pkce'; +import { IS_LITE } from '@/lib/lite'; +import { generateCodeChallenge, generateCodeVerifier } from '@/lib/oauth/pkce'; import { getDesktopBridge } from './bridge'; +import { desktopPolicyValue } from './policy'; +import type { DesktopBrowserSignInControls, DesktopPasswordSignInAnswer, DesktopPasswordSignInAttempt } from './types'; -export const DESKTOP_REDIRECT_URI_KEY = 'oauth_redirect_uri'; export const DESKTOP_CLIENT_ID_KEY = 'oauth_client_id'; export type DesktopBrowserSignInOutcome = - | { kind: 'callback'; callbackUrl: string } - | { kind: 'unavailable' } + | { kind: 'signed-in' } + /** Instance mode: what to redeem. */ + | { kind: 'code'; code: string; verifier: string } | { kind: 'declined' } + | { kind: 'invalid' } + | { kind: 'policy' } | { kind: 'cancelled' } + /** The user said no in the browser. */ + | { kind: 'denied' } + | { kind: 'timeout' } + | { kind: 'no_browser' } | { kind: 'failed'; error: string }; interface DesktopOAuthAnswer { authorization_endpoint: string; client_id: string; scope: string; + issuer?: string; + use_scheme?: boolean; } +let controls: DesktopBrowserSignInControls | null = null; +let claimedFrom: unknown; +let currentFlow: string | null = null; + +/** Before any frame exists. Whoever comes after this gets nothing from the preload. */ +export function claimBrowserSignIn(): DesktopBrowserSignInControls | null { + const bridge = getDesktopBridge(); + if (claimedFrom !== bridge) { + claimedFrom = bridge; + controls = bridge?.auth?.claim?.() ?? null; + } + return controls; +} + +/** Whether this page offers it. In a browser there's no bridge, and Lite has no server to park on. */ export function browserSignInAvailable(): boolean { - return !!getDesktopBridge()?.auth; + return !IS_LITE && !!claimBrowserSignIn() && desktopPolicyValue('BrowserSignIn') === true; } +/** The Cancel button, and leaving the page. */ +export function cancelBrowserSignIn(): void { + if (currentFlow) controls?.cancel(currentFlow); + currentFlow = null; +} + +function failure(error: string): DesktopBrowserSignInOutcome { + if (error === 'cancelled') return { kind: 'cancelled' }; + if (error === 'denied') return { kind: 'denied' }; + if (error === 'timeout') return { kind: 'timeout' }; + if (error === 'no_browser') return { kind: 'no_browser' }; + if (error === 'policy') return { kind: 'policy' }; + if (error === 'declined') return { kind: 'declined' }; + if (error === 'invalid') return { kind: 'invalid' }; + return { kind: 'failed', error }; +} + +/** Instance mode. The caller redeems the code (loginWithDesktopHandoff in the auth store). */ +export async function runInstanceBrowserSignIn(options: { base: string; locale: string; fresh: boolean }): Promise { + const auth = claimBrowserSignIn(); + if (!auth) return { kind: 'failed', error: 'unavailable' }; + const begun = await auth.begin({ kind: 'instance' }); + if (!begun.ok) return failure(begun.error); + currentFlow = begun.flowId; + try { + const verifier = generateCodeVerifier(); + const url = new URL(`${options.base}/${options.locale}/login`); + url.searchParams.set('mobile_redirect_uri', begun.redirectUri); + url.searchParams.set('mobile_state', begun.state); + url.searchParams.set('mobile_code_challenge', await generateCodeChallenge(verifier)); + if (options.fresh) url.searchParams.set('fresh_login', '1'); + const result = await auth.complete(begun.flowId, url.toString()); + if (!result.ok) return failure(result.error); + return { kind: 'code', code: result.code, verifier }; + } catch (error) { + auth.cancel(begun.flowId); + return { kind: 'failed', error: error instanceof Error ? error.name : 'error' }; + } finally { + currentFlow = null; + } +} + +/** The page main serves: each post goes through the app's own login, until one works. */ +async function runPasswordPage( + auth: DesktopBrowserSignInControls, + flowId: string, + tryPassword: (attempt: Extract) => Promise, +): Promise { + const started = await auth.beginPassword(flowId); + if (!started.ok) return failure(started.error); + for (;;) { + const attempt = await auth.nextPassword(flowId); + if (!attempt.ok) return attempt.error === 'done' ? { kind: 'signed-in' } : failure(attempt.error); + const answer = await tryPassword(attempt); + auth.answerPassword(flowId, answer); + if (answer.ok) { + const ended = await auth.outcome(flowId); + return ended.ok ? { kind: 'signed-in' } : failure(ended.error); + } + } +} + +/** Standalone. Only referenced from behind the desktop build's flag, so no other bundle holds the route. */ export async function runDesktopBrowserSignIn(options: { serverUrl: string; - callbackPath: string; slot: number; remember: boolean; addAccount: boolean; + fresh: boolean; + /** Redeems the code: loginWithOAuth in the auth store. */ + redeem: (serverUrl: string, code: string, verifier: string, redirectUri: string) => Promise; + tryPassword: (attempt: Extract) => Promise; }): Promise { - const bridge = getDesktopBridge(); - if (!bridge?.auth) return { kind: 'unavailable' }; - const { serverUrl, callbackPath, slot, remember, addAccount } = options; + const auth = claimBrowserSignIn(); + if (!auth) return { kind: 'failed', error: 'unavailable' }; + const { serverUrl, slot, remember, addAccount, fresh, redeem, tryPassword } = options; - const prepared = await bridge.servers?.prepare(serverUrl); - if (prepared && !prepared.ok) return { kind: 'declined' }; - - const { flowId, redirectUri } = await bridge.auth.beginBrowserSignIn(); + const begun = await auth.begin({ kind: 'oauth', serverUrl }); + if (!begun.ok) return failure(begun.error); + const { flowId } = begun; + currentFlow = flowId; try { const res = await apiFetch('/api/auth/desktop-oauth', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverUrl, redirectUri }), + body: JSON.stringify({ serverUrl, redirectUri: begun.redirectUri }), }); + if (res.status === 404) { + const body = (await res.json().catch(() => null)) as { error?: string } | null; + if (body?.error === 'no_oauth') return await runPasswordPage(auth, flowId, tryPassword); + } if (!res.ok) { - bridge.auth.cancelBrowserSignIn(flowId); - return { kind: 'unavailable' }; + auth.cancel(flowId); + return { kind: 'failed', error: 'discovery' }; } const answer = (await res.json()) as DesktopOAuthAnswer; + let redirectUri = begun.redirectUri; + if (answer.use_scheme) { + const scheme = await auth.useScheme(flowId); + if (!scheme) { + auth.cancel(flowId); + return { kind: 'failed', error: 'scheme' }; + } + redirectUri = scheme; + } const verifier = generateCodeVerifier(); - const challenge = await generateCodeChallenge(verifier); - const state = generateState(); - const authorizeUrl = new URL(answer.authorization_endpoint); authorizeUrl.searchParams.set('response_type', 'code'); authorizeUrl.searchParams.set('client_id', answer.client_id); authorizeUrl.searchParams.set('redirect_uri', redirectUri); authorizeUrl.searchParams.set('scope', answer.scope); - authorizeUrl.searchParams.set('state', state); - authorizeUrl.searchParams.set('code_challenge', challenge); + authorizeUrl.searchParams.set('state', begun.state); + authorizeUrl.searchParams.set('code_challenge', await generateCodeChallenge(verifier)); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); if (addAccount) authorizeUrl.searchParams.set('prompt', 'select_account'); + if (fresh) authorizeUrl.searchParams.set('prompt', 'login'); - const result = await bridge.auth.completeBrowserSignIn(flowId, authorizeUrl.toString()); - if (!result.ok) { - return result.error === 'cancelled' ? { kind: 'cancelled' } : { kind: 'failed', error: result.error }; - } - if (result.state !== state) return { kind: 'failed', error: 'state_mismatch' }; + const result = await auth.complete(flowId, authorizeUrl.toString()); + if (!result.ok) return failure(result.error); + // RFC 9207: the answer names who issued it. Main has already checked the state. + if (result.iss && answer.issuer && result.iss !== answer.issuer) return { kind: 'failed', error: 'issuer_mismatch' }; - // What the callback page expects from a same-window flow, plus the two - // values only this flow has. - sessionStorage.setItem('oauth_code_verifier', verifier); - sessionStorage.setItem('oauth_state', state); - sessionStorage.setItem('oauth_server_url', serverUrl); - sessionStorage.removeItem('oauth_server_id'); + // What the auth store's exchange reads. The code never goes near an address bar. sessionStorage.setItem('oauth_remember', String(remember)); sessionStorage.setItem('oauth_cookie_slot', String(slot)); - sessionStorage.setItem(DESKTOP_REDIRECT_URI_KEY, redirectUri); sessionStorage.setItem(DESKTOP_CLIENT_ID_KEY, answer.client_id); - if (addAccount) sessionStorage.setItem('oauth_add_account_mode', 'true'); - - const query = new URLSearchParams({ code: result.code, state: result.state }); - return { kind: 'callback', callbackUrl: `${callbackPath}?${query}` }; + try { + return (await redeem(serverUrl, result.code, verifier, redirectUri)) ? { kind: 'signed-in' } : { kind: 'failed', error: 'exchange' }; + } finally { + sessionStorage.removeItem(DESKTOP_CLIENT_ID_KEY); + } } catch (error) { - bridge.auth.cancelBrowserSignIn(flowId); - return { kind: 'failed', error: error instanceof Error ? error.message : String(error) }; + auth.cancel(flowId); + return { kind: 'failed', error: error instanceof Error ? error.name : 'error' }; + } finally { + currentFlow = null; } } diff --git a/lib/desktop/edit-attachments.ts b/lib/desktop/edit-attachments.ts new file mode 100644 index 000000000..0640a108b --- /dev/null +++ b/lib/desktop/edit-attachments.ts @@ -0,0 +1,180 @@ +/** + * "Edit" on an attachment, the page's side. The desktop app copies the file to a private + * folder, opens it in its default program and says when it was saved (the shell's + * features/os-edit.ts). What happens to a saved file is decided here and in what uses + * this: a composer offers "Update attachment" (hooks/use-composer-edits.ts), and the + * rest goes through the offers in components/desktop/edited-files-host.tsx. + * + * The organisation's word on saving attachments and exporting mail is per account and only + * known here, so it's asked at every step, as the local export does around its sink: + * before anything starts, after the bytes arrive, and before a saved file is read back. + * A policy that turns to no ends the session (the host watches), and one that isn't known + * yet counts as no for starting something, but deletes nothing. + */ + +import { create } from 'zustand'; +import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { DesktopEditBridge, DesktopEditOpenResult, DesktopEditSession } from '@/lib/desktop/types'; +import { canDownload, canExport } from '@/lib/device-policy'; +import { scopeDecided } from '@/lib/offline/offline-mode'; +import { onDeviceWipe, WipeIncompleteError } from '@/lib/offline/wipe-registry'; +import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup'; + +/** Where a session's file came from, kept by main for as long as the session lives. */ +export type EditSource = + /** A message's attachment: offers a reply. */ + | { kind: 'mail'; emailId: string; ownerAccountId?: string } + /** A draft's attachment: offers the update, in the composer holding it. */ + | { kind: 'draft'; draftId: string | null; ownerAccountId?: string } + /** Its message went or it was taken off a draft: only the file is left. */ + | { kind: 'file' }; + +export function parseEditSource(raw: string): EditSource { + try { + const parsed = JSON.parse(raw) as Partial & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown }; + const ownerAccountId = typeof parsed.ownerAccountId === 'string' ? parsed.ownerAccountId : undefined; + if (parsed.kind === 'mail' && typeof parsed.emailId === 'string') return { kind: 'mail', emailId: parsed.emailId, ownerAccountId }; + if (parsed.kind === 'draft') return { kind: 'draft', draftId: typeof parsed.draftId === 'string' ? parsed.draftId : null, ownerAccountId }; + } catch { + // Falls through: what's left is a file. + } + return { kind: 'file' }; +} + +export function getEditBridge(): DesktopEditBridge | null { + return getDesktopBridge()?.edit ?? null; +} + +/** Nothing in the browser. Otherwise the shell's own list of what may be opened. */ +export function canEditFile(name: string | null | undefined): boolean { + return !!name && getEditBridge()?.canEdit(name) === true; +} + +/** Whether this account may keep a copy outside the app: saving attachments and export both have to be allowed. */ +export function editAllowed(account: string): boolean { + return scopeDecided(account) && canDownload(account) && canExport(account); +} + +/** A definite no. An unknown policy isn't one: nothing is deleted for that. */ +export function editForbidden(account: string): boolean { + return scopeDecided(account) && !(canDownload(account) && canExport(account)); +} + +export type EditStartResult = DesktopEditOpenResult | { ok: false; reason: 'policy' | 'unavailable' | 'gesture' }; + +export interface StartEditInput { + /** One session per key: Edit on the same attachment opens the same file, with its edits. */ + key: string; + name: string; + type: string; + account: string; + source: EditSource; + load: () => Promise; +} + +/** Call it straight from the click: main only starts a session on a real one. */ +export async function startAttachmentEdit(input: StartEditInput): Promise { + const edit = getEditBridge(); + if (!edit) return { ok: false, reason: 'unavailable' }; + if (!editAllowed(input.account)) return { ok: false, reason: 'policy' }; + const begun = await edit.begin(input.key); + if (!begun.ok) return begun; + if (begun.open) return edit.reopen(begun.ticket, begun.open); + const loaded = await input.load(); + // It can take a while to arrive, and the answer can change meanwhile. + if (!editAllowed(input.account)) return { ok: false, reason: 'policy' }; + if (isAppLocked()) return { ok: false, reason: 'locked' }; + const data = new Uint8Array(loaded instanceof Blob ? await loaded.arrayBuffer() : loaded); + return edit.open(begun.ticket, { + key: input.key, + name: input.name, + type: input.type, + data, + account: input.account, + source: JSON.stringify(input.source), + }); +} + +/** A file read back remembers which session it came from, so whichever composer it lands in takes the session on. */ +const editFiles = new WeakMap(); + +export function editFileInfo(file: File | undefined): { id: string; hash: string } | undefined { + return file ? editFiles.get(file) : undefined; +} + +export type EditReadOutcome = + | { ok: true; file: File; hash: string } + | { ok: false; reason: 'policy' | 'locked' | 'missing' | 'busy' | 'too-large' | 'unavailable' }; + +export async function readEditedFile(session: Pick): Promise { + const edit = getEditBridge(); + if (!edit) return { ok: false, reason: 'unavailable' }; + if (!editAllowed(session.account)) return { ok: false, reason: 'policy' }; + const answer = await edit.read(session.id); + if (!answer.ok) return answer; + const bytes = answer.data; + const file = new File([new Uint8Array(bytes)], answer.name, { type: answer.type || 'application/octet-stream' }); + editFiles.set(file, { id: session.id, hash: answer.hash }); + return { ok: true, file, hash: answer.hash }; +} + +const REPLY_WAIT_MS = 2 * 60 * 1000; +const parkedReplies = new Map(); + +/** The next reply composer opened on this message takes these. Waits here so every way of opening one works. */ +export function parkFilesForReply(emailId: string, files: File[]): void { + parkedReplies.set(emailId, { files, at: Date.now() }); +} + +export function takeFilesForReply(emailId: string | undefined): File[] { + if (!emailId) return []; + const parked = parkedReplies.get(emailId); + parkedReplies.delete(emailId); + return parked && Date.now() - parked.at < REPLY_WAIT_MS ? parked.files : []; +} + +interface EditSessionsState { + sessions: DesktopEditSession[]; +} + +/** What main says, per window. Empty while the app is locked. */ +export const useEditSessions = create(() => ({ sessions: [] })); + +let listening = false; + +/** Once per page, and for its life. Does nothing in a browser. */ +export function followEditSessions(): void { + const edit = getEditBridge(); + if (!edit || listening) return; + listening = true; + edit.onChange((sessions) => useEditSessions.setState({ sessions })); + void edit.list().then((sessions) => useEditSessions.setState({ sessions }), () => {}); +} + +/** For tests. */ +export function resetEditSessions(): void { + listening = false; + parkedReplies.clear(); + useEditSessions.setState({ sessions: [] }); +} + +const clear = async (account: string | null) => { + await getEditBridge()?.clear(account); +}; + +const wipe = async (account: string | null) => { + const result = await getEditBridge()?.wipe(account); + if (result && result.held.length > 0) throw new WipeIncompleteError(`${result.held.length} files are held by another program`); +}; + +// The copies are of mail, so they go with the account. A wipe asks nobody; the question before +// a sign-out the user started is main's, through the host's gate. A sign-out the app decided +// on because it couldn't confirm the login (keepLocalStore) keeps them, as it keeps the store: +// nobody chose it, and these can be someone's only copy of an afternoon's work. While it lasts +// the host doesn't show them, since that account isn't signed in. +onDeviceWipe((account) => wipe(account)); +onBeforeSignOut((account, options) => (options.keepLocalStore ? Promise.resolve() : clear(account))); +onFullSignOut((options) => { + if (!options.keepLocalStore) void clear(null); +}); diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts index 37b697e94..28945be18 100644 --- a/lib/desktop/languages.ts +++ b/lib/desktop/languages.ts @@ -43,7 +43,31 @@ export function noteComposerLanguage(key: string, language: string | null): void export function shellLabels(t: (key: string, values?: Record) => string): Record { return { ...updateLabels(t), + signInDone: t('desktop_browser_sign_in.page_done'), + signInFailed: t('desktop_browser_sign_in.page_failed'), + signInLinkUsed: t('desktop_browser_sign_in.page_link_used'), + signInDeclined: t('desktop_browser_sign_in.page_declined'), + signInPageTitle: t('desktop_browser_sign_in.page_title', { server: '{server}' }), + signInPageIntro: t('desktop_browser_sign_in.page_intro', { server: '{server}' }), + signInUsername: t('desktop_browser_sign_in.page_username'), + signInPassword: t('desktop_browser_sign_in.page_password'), + signInCode: t('desktop_browser_sign_in.page_code'), + signInSubmit: t('desktop_browser_sign_in.page_submit'), + signInWrongPassword: t('desktop_browser_sign_in.page_wrong_password'), + signInCodeNeeded: t('desktop_browser_sign_in.page_code_needed'), + signInNoAnswer: t('desktop_browser_sign_in.page_no_answer'), ...certificateLabels(t), + editCancel: t('desktopEdit.cancel'), + editDiscardTitle: t('desktopEdit.discard_title', { name: '{name}' }), + editDiscardDetail: t('desktopEdit.discard_detail'), + editDiscardConfirm: t('desktopEdit.discard_confirm'), + editClearTitle: t('desktopEdit.clear_title'), + editClearConfirm: t('desktopEdit.clear_confirm'), + editSignOutTitle: t('desktopEdit.signout_title'), + editSignOutConfirm: t('desktopEdit.signout_confirm'), + editSignOutChanged: t('desktopEdit.signout_changed', { names: '{names}' }), + editSignOutHeld: t('desktopEdit.signout_held', { names: '{names}' }), + editOutsideNote: t('desktopEdit.outside_note'), menuFile: t('desktopUi.menu.file'), menuEdit: t('desktopUi.menu.edit'), menuView: t('desktopUi.menu.view'), diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts index 12cd12e5d..958ac5156 100644 --- a/lib/desktop/mail-notifications.ts +++ b/lib/desktop/mail-notifications.ts @@ -45,6 +45,8 @@ export interface MailNotificationContext { accountId?: string; slot?: number; login?: string; + /** Not mail: an attachment saved in another program (components/desktop/edited-files-host.tsx). */ + editedFile?: boolean; } /** True where this module plays the new-mail sound, so useNewMailAlerts doesn't as well. */ diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts index ede5e140b..c52c1baad 100644 --- a/lib/desktop/os-commands.ts +++ b/lib/desktop/os-commands.ts @@ -101,6 +101,11 @@ async function handleNotification(action: string, rawContext: string, reply: str } if (await handleReminderCommand(action, rawContext, env)) return; const context = parseContext(rawContext); + // A saved file's toast: the offer is on the main window, wherever it was. + if (context.editedFile) { + showWindow(); + return; + } if (action === 'reply') { await openFromNotification(context, env, { text: reply ?? '' }); return; diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index 749a8361d..db5f4fc2b 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -76,6 +76,7 @@ export interface PolicyValues { AuthNegotiateDelegateAllowlist: string | null; AutoSelectCertificateForUrls: CertificateRule[]; KeystoreAllowedOrigins: string[]; + BrowserSignIn: boolean; WipeOfflineData: string | null; } @@ -384,6 +385,18 @@ export const POLICY_SCHEMA: Record = { de: 'Die Bulwark-Server, nach https-Adresse, deren Plugins mit den Zertifikaten im Speicher des Betriebssystems signieren und entschlüsseln dürfen, ohne dass der Benutzer für jede Website gefragt wird. Eine pro Zeile.', }, }, + BrowserSignIn: { + category: 'signIn', + type: 'boolean', + default: true, + onInvalid: false, + sample: true, + title: { en: 'Offer sign-in through the browser', de: 'Anmeldung über den Browser anbieten' }, + description: { + en: 'When enabled or not configured, the login page offers "Sign in with your browser", so people can use their password manager, passkeys and your single sign-on there. When disabled, the button is gone and the app refuses the flow. The form in the app stays, unless the server only allows OAuth.', + de: 'Wenn aktiviert oder nicht konfiguriert, bietet die Anmeldeseite „Mit Ihrem Browser anmelden“ an, damit Benutzer dort ihren Passwort-Manager, Passkeys und Ihr Single Sign-on verwenden können. Wenn deaktiviert, fehlt die Schaltfläche und die App lehnt den Vorgang ab. Das Formular in der App bleibt, sofern der Server nicht nur OAuth erlaubt.', + }, + }, WipeOfflineData: { category: 'security', type: 'string', @@ -828,6 +841,7 @@ export const POLICY_CONSUMERS: Record = { QuickComposeShortcut: { repo: 'shell', file: 'src/main/features/os-quick-compose.ts' }, AutoSelectCertificateForUrls: { repo: 'shell', file: 'src/main/features/client-certificates.ts' }, KeystoreAllowedOrigins: { repo: 'shell', file: 'src/main/features/policies.ts' }, + BrowserSignIn: { repo: 'shell', file: 'src/main/features/browser-sign-in.ts' }, }; /** The part the embedded server enforces. Sent to it by message. */ diff --git a/lib/desktop/server-redirect.ts b/lib/desktop/server-redirect.ts new file mode 100644 index 000000000..64223a61d --- /dev/null +++ b/lib/desktop/server-redirect.ts @@ -0,0 +1,31 @@ +/** + * Where a mail server may send the desktop app's own server with a redirect. + * + * An approved server gets past the SSRF guard, and the guard only knows about the + * address the user approved. Following its redirects anywhere would let it aim the + * app at the LAN or at loopback, with the user's credentials on the request. So a + * hop stays on its origin, goes to another approved server, or moves the same host + * from http to https on the standard ports. Nothing else counts as the same server. + * + * A public server's hops go through the rebinding-safe fetch, which checks each of + * them. AllowedJmapServers holds for every hop, wherever it comes from. + * + * Every other build keeps its rules: a server an admin configured is the admin's. + */ + +import { mailServerRefusedByDevicePolicy } from './server-policy'; +import { isDesktopTrustedServer } from './trusted-servers'; + +function movesUpToHttps(from: URL, to: URL): boolean { + return from.protocol === 'http:' && to.protocol === 'https:' && from.hostname === to.hostname && from.port === '' && to.port === ''; +} + +/** `approved`: the request was let past the guard as an approved server. */ +export function desktopRedirectRefused(from: string | URL, to: string | URL, approved: boolean): boolean { + if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP !== '1') return false; + const source = new URL(from); + const target = new URL(to); + if (mailServerRefusedByDevicePolicy(target.href)) return true; + if (!approved || source.origin === target.origin) return false; + return !isDesktopTrustedServer(target.href) && !movesUpToHttps(source, target); +} diff --git a/lib/desktop/server-secrets.ts b/lib/desktop/server-secrets.ts new file mode 100644 index 000000000..ee8e76189 --- /dev/null +++ b/lib/desktop/server-secrets.ts @@ -0,0 +1,20 @@ +/** + * The session secret and local admin password of the desktop app's own server. + * + * The Electron main process sends them by message, and src/server/host.ts in the + * desktop repo puts them on globalThis, which all of Next's bundles share. They stay + * out of the environment block, where a child process would inherit them. + * + * Every other build reads its secrets the usual way, and this compiles to nothing. + */ + +interface ShellSecrets { + sessionSecret?: string; + adminPassword?: string; +} + +export function desktopServerSecret(name: keyof ShellSecrets): string { + if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP !== '1') return ''; + const holder = globalThis as unknown as Record; + return holder[Symbol.for('bulwark.desktop.serverSecrets')]?.[name] ?? ''; +} diff --git a/lib/desktop/trusted-servers.ts b/lib/desktop/trusted-servers.ts index e2cf034b5..8fa60c651 100644 --- a/lib/desktop/trusted-servers.ts +++ b/lib/desktop/trusted-servers.ts @@ -7,8 +7,9 @@ * or the environment can write it, so script in a page can't aim the login * routes at the LAN. * - * Callers guard this with IS_DESKTOP_BUILD, so a server build compiles the - * whole thing away. scripts/standalone-guard.mjs checks that it did. + * Callers check the flag too, but Turbopack keeps an imported export after the + * call is gone. So the check also starts here, spelled out: every bundler then + * drops the body from a server build. scripts/standalone-guard.mjs checks it did. */ type TrustedOrigins = ReadonlySet; @@ -25,6 +26,7 @@ function originOf(url: string): string | null { } export function isDesktopTrustedServer(url: string | null | undefined): boolean { + if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP !== '1') return false; if (!url) return false; const origin = originOf(url); if (!origin) return false; diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 643b9705d..c1f1a9ab4 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -190,16 +190,46 @@ export interface DesktopLifecycleBridge { onFlushRequest: (handler: () => Promise) => () => void; } -export type DesktopBrowserSignInResult = - | { ok: true; code: string; state: string } +/** Standalone: a mail server's own OAuth. Instance: the instance's own login page. */ +export type DesktopBrowserSignInRequest = { kind: 'oauth'; serverUrl: string } | { kind: 'instance' }; + +export type DesktopBrowserSignInBegin = + | { ok: true; flowId: string; redirectUri: string; state: string } | { ok: false; error: string }; +export type DesktopBrowserSignInResult = + | { ok: true; code: string; state: string; iss?: string } + | { ok: false; error: string }; + +export type DesktopPasswordSignInStart = { ok: true; host: string } | { ok: false; error: string }; + +export type DesktopPasswordSignInAttempt = + | { ok: true; username: string; password: string; totp?: string } + | { ok: false; error: string }; + +export type DesktopPasswordSignInAnswer = + | { ok: true } + | { ok: false; error: 'invalid_credentials' | 'totp_required' | 'other' }; + +/** Sign-in through the system browser. See lib/desktop/browser-sign-in.ts. */ +export interface DesktopBrowserSignInControls { + /** A short-lived loopback listener for one sign-in. Only from a click. */ + begin: (request: DesktopBrowserSignInRequest) => Promise; + /** Opens the system browser on `url` and waits for the redirect. */ + complete: (flowId: string, url: string) => Promise; + /** The server refused loopback and took the `bulwark:` scheme. */ + useScheme: (flowId: string) => Promise; + cancel: (flowId: string) => void; + /** The server has no OAuth: a page main serves on a stable loopback port. */ + beginPassword: (flowId: string) => Promise; + nextPassword: (flowId: string) => Promise; + answerPassword: (flowId: string, answer: DesktopPasswordSignInAnswer) => void; + outcome: (flowId: string) => Promise; +} + export interface DesktopAuthBridge { - /** Starts a short-lived loopback listener for one sign-in. */ - beginBrowserSignIn: () => Promise<{ flowId: string; redirectUri: string }>; - /** Opens the system browser on `authorizeUrl` and waits for the redirect. */ - completeBrowserSignIn: (flowId: string, authorizeUrl: string) => Promise; - cancelBrowserSignIn: (flowId: string) => void; + /** Once per document, in the first render: a same-origin frame can reach this bridge. */ + claim: () => DesktopBrowserSignInControls | null; } export type DesktopPluginInstallResult = @@ -635,6 +665,99 @@ export interface DesktopPrivacyBridge { onChange: (listener: (state: DesktopPrivacyState) => void) => () => void; } +/** An attachment copied to a private folder and open in its default program. */ +export interface DesktopEditSession { + id: string; + name: string; + type: string; + /** The local account it belongs to. */ + account: string; + /** What the page said it came from. Main only keeps it. */ + source: string; + /** Saved differently from what the page last took. */ + changed: boolean; + /** Goes up with each save. */ + version: number; + /** What the file holds now: a chip holding this version has nothing to update. */ + hash: string; + /** A composer has it and offers the update itself. */ + claimed: boolean; + /** Wanted gone, and another program has it open. */ + held: boolean; + /** Saved under a new name in that program. */ + savedAs: boolean; + /** Not used for 30 days: this offer is the last. */ + lastChance: boolean; +} + +/** What couldn't be deleted because another program has it open, by file name. `refused`: nothing happened. */ +export interface DesktopEditClearResult { + held: string[]; + refused?: boolean; +} + +export interface DesktopEditFile { + /** One session per key: Edit on the same attachment opens the same file. */ + key: string; + name: string; + type: string; + data: Uint8Array; + account: string; + source: string; +} + +export type DesktopEditBeginResult = + /** One click, one ticket, for that key. A session for it exists: reopen this one. */ + | { ok: true; ticket: string; open?: string } + | { ok: false; reason: 'locked' | 'gesture' }; + +export type DesktopEditOpenResult = + | { ok: true; id: string } + | { ok: false; reason: 'locked' | 'ticket' | 'type' | 'too-large' | 'full' | 'no-app' | 'failed' }; + +export type DesktopEditReadResult = + | { ok: true; name: string; type: string; data: Uint8Array; hash: string } + | { ok: false; reason: 'locked' | 'missing' | 'busy' | 'too-large' }; + +/** + * Edit on an attachment: a private copy opened in its default program, watched for saves. + * Main refuses file types that run something and anything while locked. It can't know the + * organisation's rules, so lib/desktop/edit-attachments.ts asks those at each step. + */ +export interface DesktopEditBridge { + /** Whether this file may go through Edit at all: the same rule main enforces. */ + canEdit: (name: string) => boolean; + /** Straight from the click: main spends it. The ticket lasts long enough to fetch the bytes, for this key only. */ + begin: (key: string) => Promise; + open: (ticket: string, file: DesktopEditFile) => Promise; + reopen: (ticket: string, id: string) => Promise; + /** Empty while locked. */ + list: () => Promise; + /** The list after looking at each file now, for a decision that can't wait for the watcher. */ + settle: () => Promise; + read: (id: string) => Promise; + /** This version is where it should be, so it's no longer a change. */ + taken: (id: string, hash: string) => Promise; + /** The draft it was on has gone: deleted, unless it holds a save nobody took. Main decides, as it knows. */ + release: (id: string, source: string) => Promise; + /** Asks natively if it holds edits. */ + discard: (id: string) => Promise; + /** The organisation said no: no question. */ + forceDiscard: (id: string) => Promise; + /** The question before a sign-out, native, naming what a program has open. A yes covers the `clear` that follows. */ + askClear: (account: string) => Promise; + /** An account's copies, or with null all of this origin's. Asks natively about edits, without that yes. */ + clear: (account: string | null) => Promise; + /** A wipe: no question. */ + wipe: (account: string | null) => Promise; + /** The page has shown a last offer. */ + shown: (id: string) => void; + setSource: (id: string, source: string) => void; + /** A composer holds it, or stopped. Ends by itself with the page. */ + claim: (id: string, on: boolean) => void; + onChange: (listener: (sessions: DesktopEditSession[]) => void) => () => void; +} + export interface BulwarkDesktopBridge { version: number; platform: 'win32' | 'darwin' | 'linux'; @@ -659,6 +782,7 @@ export interface BulwarkDesktopBridge { appLock?: DesktopAppLockBridge; localImport?: DesktopLocalImportBridge; privacy?: DesktopPrivacyBridge; + edit?: DesktopEditBridge; } declare global { diff --git a/lib/lite-config.ts b/lib/lite-config.ts index 1a2ce4d6a..7a9aa5891 100644 --- a/lib/lite-config.ts +++ b/lib/lite-config.ts @@ -89,6 +89,7 @@ export function applyLiteConfig(raw: unknown, defaults?: { jmapServerUrl?: strin loginShowSubtitle: bool(input.loginShowSubtitle, true), loginShowTotp: bool(input.loginShowTotp, true), loginShowTokenLogin: bool(input.loginShowTokenLogin, false), + desktopBrowserSignIn: false, loginShowRememberMe: bool(input.loginShowRememberMe, false), loginShowVersion: bool(input.loginShowVersion, true), demoMode: bool(input.demoMode, false), diff --git a/lib/oauth/discovery.ts b/lib/oauth/discovery.ts index e4964d459..6c52e7126 100644 --- a/lib/oauth/discovery.ts +++ b/lib/oauth/discovery.ts @@ -5,6 +5,7 @@ export interface OAuthMetadata { revocation_endpoint?: string; end_session_endpoint?: string; scopes_supported?: string[]; + registration_endpoint?: string; } // Validates that a discovered endpoint URL is safe to follow. Server-side @@ -148,6 +149,8 @@ async function attemptDiscovery( token_endpoint: data.token_endpoint, revocation_endpoint: data.revocation_endpoint, end_session_endpoint: data.end_session_endpoint, + // Not followed from here: the one caller checks it itself (app/api/auth/desktop-oauth). + ...(typeof data.registration_endpoint === 'string' ? { registration_endpoint: data.registration_endpoint } : {}), ...(Array.isArray(data.scopes_supported) ? { scopes_supported: data.scopes_supported.filter((s: unknown): s is string => typeof s === 'string') } : {}), diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts index faface568..5fc428f2c 100644 --- a/lib/offline/device-wipe.ts +++ b/lib/offline/device-wipe.ts @@ -265,6 +265,7 @@ async function endWebPushIfNobodyLeft(): Promise { const registration = await pushRegistration(); const subscription = await registration?.pushManager?.getSubscription(); await subscription?.unsubscribe(); + removeLocalKeys([], ['bulwark.push.']); } /** With nobody left after this there's nothing to lock for, and a next person shouldn't meet it. */ @@ -274,12 +275,13 @@ async function forgetLockIfNobodyStays(leaving: string | null): Promise { } /** What's the account's own: a deletion after a refused login goes no further than this. */ -async function scrubAccount(account: string): Promise { - await runWipeCleaners(account); +async function scrubAccount(account: string): Promise { + const complete = await runWipeCleaners(account); await forgetLocalImportOf(account).catch(() => {}); await forgetPluginAccounts(account).catch(() => {}); clearPluginStorageForAccount(account); forgetMailSnapshot(account); + return complete; } /** Everything accounts share, or that can't be told apart by account. */ @@ -418,7 +420,8 @@ async function eraseAccount(account: string, reason: WipeReason, options: WipeAc } clearEvicted(account); dropRefusedLogin(account); - await scrubAccount(account).catch(() => {}); + // A copy a program still holds: tried again at the next start, like the store. + if (!(await scrubAccount(account).catch(() => true))) deleted = false; if (!ownOnly) await scrubShared().catch(() => {}); await deleteDeviceRecord(account).catch(() => {}); @@ -478,11 +481,11 @@ async function eraseDevice(reason: WipeReason): Promise { } confirmLoss('all'); - await runWipeCleaners(null); + const cleaned = await runWipeCleaners(null); await forgetPluginAccounts(null).catch(() => {}); clearAllPluginStorage(); await scrubShared().catch(() => {}); - let deleted = true; + let deleted = cleaned; await wipeEverything().catch(() => { deleted = false; }); for (const account of Object.keys(readEvicted())) clearEvicted(account); for (const account of Object.keys(readRefusedLogins())) dropRefusedLogin(account); diff --git a/lib/offline/evicted-secrets.ts b/lib/offline/evicted-secrets.ts index 490992645..4929a7f34 100644 --- a/lib/offline/evicted-secrets.ts +++ b/lib/offline/evicted-secrets.ts @@ -1,8 +1,9 @@ /** * Plugin secrets of an account the app dropped, in the desktop app, where they sit - * on this device's own server. The account has no cookie left to ask with, so the - * route takes who it was, from what was written down at the time. On an instance - * they aren't the device's to remove, and nothing is written down. + * on this device's own server. The account has no session cookie left to ask with, so + * the route takes who it was, from what was written down at the time, with the proof + * the account got while it was signed in. On an instance they aren't the device's to + * remove, and nothing is written down. */ import { apiFetch } from '@/lib/browser-navigation'; diff --git a/lib/offline/wipe-registry.ts b/lib/offline/wipe-registry.ts index 2b9d8f393..525d25ea9 100644 --- a/lib/offline/wipe-registry.ts +++ b/lib/offline/wipe-registry.ts @@ -8,10 +8,17 @@ * `null` when everything on this device goes. A cleaner should finish quickly * and may not ask the user anything. Bridge calls are fine, network isn't: a * wipe often runs because the server said no. + * + * A cleaner that can't finish, as a program holds a file, throws WipeIncompleteError: the wipe + * then doesn't count as done, isn't reported as done, and runs again at the next start. */ export type WipeCleaner = (account: string | null) => void | Promise; +export class WipeIncompleteError extends Error { + override name = 'WipeIncompleteError'; +} + const cleaners = new Set(); export function onDeviceWipe(cleaner: WipeCleaner): () => void { @@ -22,7 +29,9 @@ export function onDeviceWipe(cleaner: WipeCleaner): () => void { /** How long a wipe waits for one cleaner. What's on screen waits with it. */ const CLEANER_BUDGET_MS = 10_000; -export async function runWipeCleaners(account: string | null): Promise { +/** False when a cleaner says it couldn't finish. */ +export async function runWipeCleaners(account: string | null): Promise { + let complete = true; await Promise.all([...cleaners].map(async (cleaner) => { let timer: ReturnType | undefined; try { @@ -30,10 +39,12 @@ export async function runWipeCleaners(account: string | null): Promise { cleaner(account), new Promise((resolve) => { timer = setTimeout(resolve, CLEANER_BUDGET_MS); }), ]); - } catch { + } catch (error) { // One feature failing must not keep the others' data. + if ((error as Error | undefined)?.name === 'WipeIncompleteError') complete = false; } finally { if (timer) clearTimeout(timer); } })); + return complete; } diff --git a/lib/plugin-secrets/wipe-proof.ts b/lib/plugin-secrets/wipe-proof.ts new file mode 100644 index 000000000..cb8a32feb --- /dev/null +++ b/lib/plugin-secrets/wipe-proof.ts @@ -0,0 +1,42 @@ +/** + * Proof that this browser had a session for an account, for the desktop app. + * + * An account the app dropped has no session cookie left, and its plugin secrets + * still sit on this device until a wipe. So when a signed-in account stores or lists + * them, it also gets this sealed cookie, and a wipe by name needs the one for that + * name. Nobody can then clear an account's secrets by knowing its address. + * + * It opens nothing else, and only the desktop build writes or reads it. + */ + +import type { NextRequest, NextResponse } from 'next/server'; +import { decryptPayload, encryptPayload } from '@/lib/auth/crypto'; +import { getCookieOptions } from '@/lib/oauth/cookie-config'; +import { userKey, type VaultUser } from './vault'; + +/** Chromium's upper limit. A dropped account can stay dropped for a long time. */ +const MAX_AGE_SECONDS = 400 * 24 * 60 * 60; + +function cookieName(key: string): string { + return `jmap_psp_${key.slice(0, 16)}`; +} + +export function setWipeProof(response: NextResponse, user: VaultUser): void { + const key = userKey(user); + response.cookies.set(cookieName(key), encryptPayload({ u: key }, 'plugin-wipe-proof'), { ...getCookieOptions(), maxAge: MAX_AGE_SECONDS }); +} + +export function clearWipeProof(response: NextResponse, user: VaultUser): void { + response.cookies.set(cookieName(userKey(user)), '', { ...getCookieOptions(), maxAge: 0 }); +} + +export function hasWipeProof(request: NextRequest, user: VaultUser): boolean { + const key = userKey(user); + const value = request.cookies.get(cookieName(key))?.value; + if (!value) return false; + try { + return decryptPayload(value, 'plugin-wipe-proof')?.u === key; + } catch { + return false; + } +} diff --git a/lib/popout/open.ts b/lib/popout/open.ts index d3c6785dc..e22defeac 100644 --- a/lib/popout/open.ts +++ b/lib/popout/open.ts @@ -81,6 +81,12 @@ export function openNewComposerWindow(localAccountId?: string | null): boolean { return focused(openWindow(appPath('/popout/compose') + query({ account: localAccountId }), name, 'compose')); } +/** A saved draft in a composer window of its own. The same draft twice focuses the same window. */ +export function openDraftWindow(target: { draftId: string; localAccountId?: string | null; ownerAccountId?: string | null }): boolean { + const url = appPath('/popout/compose') + query({ draft: target.draftId, account: target.localAccountId, owner: target.ownerAccountId }); + return focused(openWindow(url, `bulwark-compose-draft-${target.draftId.replace(/\W/g, '_')}`, 'compose')); +} + export interface ComposerWindow { /** Hands the draft over. True once the composer is on screen there; until then the sender keeps its copy. */ handOver(payload: ComposePopoutPayload): Promise; diff --git a/lib/push-preferences.ts b/lib/push-preferences.ts index a148e34a9..31c71558f 100644 --- a/lib/push-preferences.ts +++ b/lib/push-preferences.ts @@ -20,6 +20,23 @@ export function pushPreferencesKey(origin: string): string { return `${origin}${BASE_PATH}/__push-state/prefs`; } +/** + * True for a login with "Email notifications" off or do not disturb on. The worker + * shows nothing for its mail. Per login, as settings are: the store only holds the + * active login's, and the other logins' mail must not go quiet because of them. + */ +export async function writeLoginQuiet(login: string, quiet: boolean): Promise { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open(PUSH_STATE_CACHE); + const key = `${window.location.origin}${BASE_PATH}/__push-state/quiet-logins`; + const stored = (await (await cache.match(key))?.json().catch(() => null)) as Record | null; + await cache.put(key, new Response(JSON.stringify({ ...stored, [login]: quiet }), { headers: { 'Content-Type': 'application/json' } })); + } catch { + // Without the store the worker shows every push. + } +} + export async function writePushPreferences(prefs: PushPreferences): Promise { if (typeof window === 'undefined') return; try { diff --git a/lib/pwa-notify/worker-state.ts b/lib/pwa-notify/worker-state.ts index 161f3885c..034d4efa0 100644 --- a/lib/pwa-notify/worker-state.ts +++ b/lib/pwa-notify/worker-state.ts @@ -29,6 +29,71 @@ export function pendingReplyKey(origin: string): string { return `${origin}${BASE_PATH}/__push-state/pending-reply`; } +/** The cache key sw.js finds the relay registrations under. */ +export function relayRegistrationsKey(origin: string): string { + return `${origin}${BASE_PATH}/__push-state/relay-registrations`; +} + +export interface RelayRegistration { + relay: string; + subscriptionId: string; + accountLabel?: string; +} + +interface RelayRegistrations { + applicationServerKey?: string; + entries: Record; +} + +async function readStored(cache: Cache, key: string): Promise { + const stored = (await (await cache.match(key))?.json()) as RelayRegistrations | undefined; + return stored?.entries ? stored : { entries: {} }; +} + +/** Every login's relay registration on this browser, by JMAP account id. */ +export async function readRelayRegistrations(): Promise> { + try { + if (typeof caches === 'undefined') return {}; + const cache = await caches.open(PUSH_STATE_CACHE); + return (await readStored(cache, relayRegistrationsKey(window.location.origin))).entries; + } catch { + return {}; + } +} + +/** + * Lets the worker re-register a new browser endpoint with the relay when no tab + * is open (pushsubscriptionchange). None of this is secret to the browser that + * subscribed, and it opens nothing but the relay record. + */ +export async function keepRelayRegistration(accountId: string, entry: RelayRegistration, applicationServerKey: string): Promise { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open(PUSH_STATE_CACHE); + const key = relayRegistrationsKey(window.location.origin); + const stored = await readStored(cache, key); + stored.entries[accountId] = entry; + stored.applicationServerKey = applicationServerKey; + await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } })); + } catch { + // A new endpoint then waits for the page's re-sync. + } +} + +export async function forgetRelayRegistration(accountId: string): Promise { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open(PUSH_STATE_CACHE); + const key = relayRegistrationsKey(window.location.origin); + const stored = await readStored(cache, key); + if (!(accountId in stored.entries)) return; + delete stored.entries[accountId]; + await cache.put(key, new Response(JSON.stringify(stored), { headers: { 'Content-Type': 'application/json' } })); + } catch { + // Signing out deletes the store anyway. + } +} + export async function writeNotificationActionPrefs(prefs: NotificationActionPrefs): Promise { try { if (typeof caches === 'undefined') return; diff --git a/lib/shared-store/server-state-allowlist.ts b/lib/shared-store/server-state-allowlist.ts index a46473fd5..fe67a2193 100644 --- a/lib/shared-store/server-state-allowlist.ts +++ b/lib/shared-store/server-state-allowlist.ts @@ -45,6 +45,7 @@ export const SERVER_STATE_ALLOWLIST: AllowedState[] = [ { site: 'lib/auth/client-address.ts#NOT_A_CLIENT', class: 'per-process', why: 'a constant address list built once' }, { site: 'lib/auth/handoff-store.ts#createBudget', class: 'store-backed', why: 'createFailureBudget: shared counters in shared mode' }, { site: 'lib/auth/handoff-store.ts#redeemBudget', class: 'store-backed', why: 'createFailureBudget: shared counters in shared mode' }, + { site: 'lib/auth/handoff-store.ts#desktopParkBudget', class: 'store-backed', why: 'createFailureBudget: shared counters in shared mode' }, { site: 'lib/auth/session-age.ts#missingSecretLogged', class: 'per-process', why: 'log once per process' }, { site: 'lib/auth/session-secret.ts#globalThis:SHARED_CACHE', class: 'store-backed', why: 'the generated secret read from the store at boot; it never changes' }, { site: 'lib/auth/stalwart-password-login.ts#defaultClients', class: 'cache', why: 'Stalwart\'s public client id, 10 min; registration is deterministic' }, diff --git a/lib/stalwart/jmap-api.ts b/lib/stalwart/jmap-api.ts index dfe1cd0e7..660267896 100644 --- a/lib/stalwart/jmap-api.ts +++ b/lib/stalwart/jmap-api.ts @@ -17,6 +17,7 @@ * this process — see calendar-agenda's session handling). */ +import { desktopRedirectRefused } from '@/lib/desktop/server-redirect'; import { fetchJmapServer } from '@/lib/stalwart/server-fetch'; import { UPSTREAM_JSON_MAX_BYTES, readJsonCapped } from '@/lib/security/capped-body'; import { forwardedClientHeaders } from '@/lib/auth/client-address'; @@ -88,7 +89,7 @@ export async function postJmap( if (!location) return response; const next = new URL(location, url); - if (!isTrustedRedirect(url, next)) { + if (!isTrustedRedirect(url, next) || desktopRedirectRefused(url, next, options.trusted !== false)) { throw new JmapRedirectError( `JMAP endpoint redirected to an untrusted host: ${next.host}`, ); diff --git a/lib/stalwart/push-auth-context.ts b/lib/stalwart/push-auth-context.ts new file mode 100644 index 000000000..5541da924 --- /dev/null +++ b/lib/stalwart/push-auth-context.ts @@ -0,0 +1,51 @@ +import { cookies } from 'next/headers'; +import { configManager } from '@/lib/admin/config-manager'; +import { decryptSession, sessionAuthHeader } from '@/lib/auth/crypto'; +import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor } from '@/lib/auth/session-age'; +import { sessionCookieName } from '@/lib/auth/session-cookie'; +import { readImpersonationConfig } from '@/lib/impersonation/master-config'; +import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context'; + +type CookieStore = Awaited>; + +async function limitsOnlyAPageEnforces(): Promise { + await configManager.ensureLoaded?.(); + const policy = configManager.getPolicy(); + if ((policy.organisation?.session?.idleMinutes ?? 0) > 0) return true; + return (policy.scopes ?? []).some((scope) => { + const session = scope.policy?.organisation?.session; + if ((session?.idleMinutes ?? 0) > 0) return true; + return (session?.maxHours ?? 0) > 0 && (scope.match?.groups?.length ?? 0) > 0; + }); +} + +/** + * The login for a request from the service worker, which has no page to refresh + * anything. The context cookie goes with the browser session, so a browser that + * was closed and woken again by a push only has the remembered one, which a page + * would turn back into a context on load (GET /api/auth/session, with the same + * checks). Not signed in with "remember me", or by OAuth with an expired token: + * none, and the worker shows its generic notification. + * + * A page also applies limits this can't: the idle timeout and maximums by group + * end the session when it loads. With either set anywhere, a closed browser's + * notifications stay generic and its buttons open the app. + */ +export async function readPushAuthContext(cookieStore: CookieStore, slot: number): Promise { + const context = readStalwartAuthContextFromStore(cookieStore, slot); + if (context) return context; + const token = cookieStore.get(sessionCookieName(slot))?.value; + if (!token) return null; + const credentials = decryptSession(token); + if (!credentials) return null; + // Without a domain the login can't be matched against the device policy either, + // and "Push without sender and subject" could miss it (no account name here). + if (!credentials.username.includes('@')) return null; + if (await limitsOnlyAPageEnforces()) return null; + const impersonation = readImpersonationConfig(); + if (impersonation && credentials.password === impersonation.masterPassword) return null; + const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince(); + const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]); + if (isPastMaxAge(signedInAt, maxHours)) return null; + return { serverUrl: credentials.serverUrl, username: credentials.username, authHeader: sessionAuthHeader(credentials) }; +} diff --git a/lib/stalwart/server-fetch.ts b/lib/stalwart/server-fetch.ts index 7e0296cf2..d5e657732 100644 --- a/lib/stalwart/server-fetch.ts +++ b/lib/stalwart/server-fetch.ts @@ -20,6 +20,7 @@ import { logger } from '@/lib/logger'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { fetchPublicUrl, type PublicFetchResponse } from '@/lib/security/url-guard'; import { observeUpstream } from '@/lib/metrics/upstream'; +import { desktopRedirectRefused } from '@/lib/desktop/server-redirect'; import { isStoredCredentialRefused, recordStoredCredentialStatus, @@ -100,7 +101,9 @@ async function fetchUnbudgeted( // endpoints on, one the user chose) must not pin a connection for as long // as it likes. Callers with their own signal keep it. const init: ServerFetchInit = givenInit.signal ? givenInit : { ...givenInit, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) }; - if (trusted !== false) { + // The desktop app's approved server gets its hops checked by hand as well. + const approvedOnDesktop = process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && trusted !== false; + if (trusted !== false && !approvedOnDesktop) { return fetch(url, init); } @@ -111,12 +114,10 @@ async function fetchUnbudgeted( let body = rest.body; for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { - const response: PublicFetchResponse = await fetchPublicUrl(currentUrl, { - ...(rest as Omit), - method, - body: body as never, - headers, - }); + const hop = { ...(rest as Omit), method, body: body as never, headers }; + const response: PublicFetchResponse = approvedOnDesktop + ? ((await fetch(currentUrl, { ...hop, redirect: 'manual' } as RequestInit)) as unknown as PublicFetchResponse) + : await fetchPublicUrl(currentUrl, hop); if (redirect === 'manual' || response.status < 300 || response.status >= 400) { return response as unknown as Response; @@ -129,6 +130,9 @@ async function fetchUnbudgeted( await response.body?.cancel().catch(() => {}); const next = new URL(location, currentUrl); + if (desktopRedirectRefused(currentUrl, next, approvedOnDesktop)) { + throw new Error(`${currentUrl} redirected to a server that has not been approved`); + } if (next.origin !== new URL(currentUrl).origin) { for (const key of Object.keys(headers)) { if (key.toLowerCase() === 'authorization' || key.toLowerCase() === 'cookie') { diff --git a/lib/web-push-errors.ts b/lib/web-push-errors.ts new file mode 100644 index 000000000..69d5f8573 --- /dev/null +++ b/lib/web-push-errors.ts @@ -0,0 +1,32 @@ +import { jmapErrorType } from '@/lib/jmap/jmap-error'; + +type WebPushStep = 'blocked' | 'not-granted' | 'relay' | 'verification-timeout' | 'teardown'; + +/** A step that failed for a reason the user can act on. The message stays for logs. */ +export class WebPushStepError extends Error { + constructor(public readonly step: WebPushStep, message: string) { + super(message); + this.name = 'WebPushStepError'; + } +} + +export type WebPushErrorKey = + | 'error_blocked' | 'error_not_granted' | 'error_relay' | 'error_verification_timeout' | 'error_teardown' + | 'error_forbidden' | 'error_over_quota'; + +const STEP_MESSAGES: Record = { + blocked: 'error_blocked', + 'not-granted': 'error_not_granted', + relay: 'error_relay', + 'verification-timeout': 'error_verification_timeout', + teardown: 'error_teardown', +}; + +/** The key under settings.notifications.push that says what went wrong, if any does. */ +export function webPushErrorKey(error: unknown): WebPushErrorKey | null { + if (error instanceof WebPushStepError) return STEP_MESSAGES[error.step]; + const type = jmapErrorType(error); + if (type === 'forbidden') return 'error_forbidden'; + if (type === 'overQuota') return 'error_over_quota'; + return null; +} diff --git a/lib/web-push.ts b/lib/web-push.ts index 4ab2e43de..d583ee993 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -8,11 +8,13 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { isDesktopApp } from '@/lib/desktop/bridge'; import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types'; import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error'; +import { WebPushStepError } from '@/lib/web-push-errors'; import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays'; import { isBuiltInHost, relayAllowed } from '@/lib/outgoing-hosts'; import { noThirdPartyTraffic } from '@/lib/third-party-mode'; import { IS_LITE } from '@/lib/lite'; import { serviceWorkerUrl, SW_SCOPE } from '@/lib/app-update'; +import { forgetRelayRegistration, keepRelayRegistration, readRelayRegistrations } from '@/lib/pwa-notify/worker-state'; import { EMPTY_NOTIFICATION_MUTES, isAccountMuted, @@ -51,6 +53,12 @@ function relayBaseKey(accountId: string): string { return RELAY_BASE_PREFIX + accountId; } +/** The browser endpoint this account last gave the relay: see relayDroppedOurEndpoint. */ +const ENDPOINT_PREFIX = 'bulwark.push.endpoint.v1.'; +function endpointKey(accountId: string): string { + return ENDPOINT_PREFIX + accountId; +} + function emailPushRefusedKey(accountId: string): string { return EMAIL_PUSH_REFUSED_PREFIX + accountId; } @@ -420,6 +428,8 @@ export interface EnableWebPushParams { // that outlives a permission change keeps pushing for mailboxes the user can // no longer read - recreating is the only client-side remedy (#841). forceRecreate?: boolean; + // For a re-sync: stop if another tab turned push off while this waited its turn. + onlyIfStillOn?: boolean; // Scope OS push notifications to Inbox-only mail. Mirrors settings-store's // pushNotifyInboxOnly. Required, not optional: it changes the server-side // delivery filter, so every caller must decide explicitly. @@ -525,12 +535,14 @@ function readPushKey( } async function fetchVapidPublicKey(relayBaseUrl: string): Promise { - const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key')); + const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key')).catch((err: unknown) => { + throw new WebPushStepError('relay', `Could not reach the push relay: ${String(err)}`); + }); if (!res.ok) { if (res.status === 503) { throw new Error('The push relay does not have Web Push configured'); } - throw new Error(`Failed to fetch VAPID key: ${res.status}`); + throw new WebPushStepError('relay', `Failed to fetch VAPID key: ${res.status}`); } const body = (await res.json()) as { publicKey?: string }; if (!body.publicKey) throw new Error('Relay returned an empty VAPID key'); @@ -540,11 +552,11 @@ async function fetchVapidPublicKey(relayBaseUrl: string): Promise { async function ensurePermission(): Promise { if (Notification.permission === 'granted') return; if (Notification.permission === 'denied') { - throw new Error('Notifications are blocked - allow them in browser settings to continue'); + throw new WebPushStepError('blocked', 'Notifications are blocked - allow them in browser settings to continue'); } const result = await Notification.requestPermission(); if (result !== 'granted') { - throw new Error('Notification permission was not granted'); + throw new WebPushStepError(result === 'denied' ? 'blocked' : 'not-granted', 'Notification permission was not granted'); } } @@ -583,9 +595,11 @@ async function registerWithRelay(params: { subscription: { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } }, accountLabel: params.accountLabel, }), + }).catch((err: unknown) => { + throw new WebPushStepError('relay', `Could not reach the push relay: ${String(err)}`); }); if (!res.ok) { - throw new Error(`Relay register failed: ${res.status}`); + throw new WebPushStepError('relay', `Relay register failed: ${res.status}`); } } @@ -632,28 +646,85 @@ async function relayReportsDead( return (await relayStatusFor(relayBaseUrl, subscriptionId)) === 'inactive'; } +// Stalwart rate-limits PushVerification per account (60 s by default). +const VERIFICATION_RESEND_AFTER_MS = 62_000; +const VERIFICATION_TIMEOUT_MS = 80_000; + +/** + * The relay deletes a record when the push service answers 404 or 410. The browser + * doesn't get told: it keeps the subscription, and registering that endpoint again + * only lasts until the next push. Only for the endpoint this account registered: + * after another account moved the browser on, a missing record means nothing. + */ +async function relayDroppedOurEndpoint( + accountId: string, + relayBaseUrl: string, + subscriptionId: string, + endpoint: string, +): Promise { + if (localStorage.getItem(relayBaseKey(accountId)) !== relayBaseUrl) return false; + if (localStorage.getItem(endpointKey(accountId)) !== endpoint) return false; + try { + const res = await fetch(buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`)); + if (res.status !== 404) return false; + // An old relay without this route answers 404 for everything. + const body = (await res.json().catch(() => null)) as { error?: unknown } | null; + return body?.error === 'Unknown subscription'; + } catch { + return false; + } +} + +/** The browser moved to a new endpoint: every other login's relay record has to follow, connected in this tab or not. */ +async function moveOtherLogins(accountId: string, subscription: PushSubscription): Promise { + const others = Object.entries(await readRelayRegistrations()).filter(([id]) => id !== accountId); + for (const [, entry] of others) { + await registerWithRelay({ + relayBaseUrl: entry.relay, + subscriptionId: entry.subscriptionId, + subscription: { endpoint: subscription.endpoint, keys: { p256dh: readPushKey(subscription, 'p256dh'), auth: readPushKey(subscription, 'auth') } }, + accountLabel: entry.accountLabel, + }).catch(() => undefined); + } +} + async function pollVerificationCode( relayBaseUrl: string, subscriptionId: string, + askAgain: () => Promise, ): Promise { - // Stalwart per-account rate-limits PushVerification posts (default 60s). - // If there are leftover unverified subscriptions on the account, our new - // one queues up behind them - so we wait long enough to clear one verify - // window even in the unlucky case. - const timeoutAt = Date.now() + 75_000; + // If there are leftover unverified subscriptions on the account, our new one + // queues up behind them, so we wait long enough to clear one verify window. + // + // Stalwart hands out the lowest free subscription id, so one destroyed and + // created again within the window (off and on again, or a retry) gets the same + // id. It takes that for a repeat and drops the verification for good, on 0.16.27 + // and 1.0 alike (push.rs: `verified_id == subscription.id`). Any change to the + // subscription after the window makes it send one. + const startedAt = Date.now(); + const timeoutAt = startedAt + VERIFICATION_TIMEOUT_MS; + let askedAgain = false; let delay = 400; while (Date.now() < timeoutAt) { - const res = await fetch( - buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`), - ); - if (res.ok) { - const body = (await res.json()) as { verificationCode?: string | null }; - if (body.verificationCode) return body.verificationCode; + if (!askedAgain && Date.now() - startedAt >= VERIFICATION_RESEND_AFTER_MS) { + askedAgain = true; + await askAgain().catch(() => undefined); + } + try { + const res = await fetch( + buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`), + ); + if (res.ok) { + const body = (await res.json()) as { verificationCode?: string | null }; + if (body.verificationCode) return body.verificationCode; + } + } catch { + // A blip on the way to the relay; the code is still coming. } await new Promise((r) => setTimeout(r, delay)); delay = Math.min(delay * 1.5, 2000); } - throw new Error('Timed out waiting for PushVerification from the JMAP server'); + throw new WebPushStepError('verification-timeout', 'Timed out waiting for PushVerification from the JMAP server'); } // Push the subscription's expiry forward and re-sync `types` / the delivery @@ -701,6 +772,28 @@ async function refreshSubscriptionExpires( } } +// Another tab may be on the same steps, and the relay and the server only know +// one registration per device: one tab would reap the subscription the other is +// verifying. Tabs take turns, in every browser that has Web Locks. Whoever comes +// second finds the first one's work and only refreshes it. +function oneTabAtATime(run: () => Promise): Promise { + const limited = () => withinPushSetupLimit(run); + const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined; + return locks ? locks.request('bulwark-web-push', limited) as Promise : limited(); +} + +// A worker that never gets ready or a subscribe() that never answers would hold up +// every tab and every login, for good. Verification alone can take 80 s. +const PUSH_SETUP_LIMIT_MS = 120_000; + +function withinPushSetupLimit(run: () => Promise): Promise { + let timer: ReturnType | undefined; + const limit = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new WebPushStepError('verification-timeout', 'Push setup took too long')), PUSH_SETUP_LIMIT_MS); + }); + return Promise.race([run(), limit]).finally(() => clearTimeout(timer)); +} + export async function enableWebPush( params: EnableWebPushParams, ): Promise { @@ -713,7 +806,18 @@ export async function enableWebPush( const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); if (!relayBaseUrl) throw new Error('relayBaseUrl is required'); + // Outside the lock: the browser's question must come with the user's click. await ensurePermission(); + return oneTabAtATime(() => enableWebPushForThisTab(params, relayBaseUrl)); +} + +async function enableWebPushForThisTab( + params: EnableWebPushParams, + relayBaseUrl: string, +): Promise { + if (params.onlyIfStillOn && !localStorage.getItem(subscriptionIdKey(params.client.getAccountId()))) { + return { subscriptionId: '' }; + } const registration = await ensureServiceWorker(); const vapidPublicKey = await fetchVapidPublicKey(relayBaseUrl); @@ -743,16 +847,24 @@ export async function enableWebPush( await pushSubscription.unsubscribe().catch(() => undefined); pushSubscription = null; } + const accountId = params.client.getAccountId(); + const deviceClientId = getOrCreateDeviceClientId(accountId); + let endpointGivenUp = false; + if (pushSubscription && await relayDroppedOurEndpoint(accountId, relayBaseUrl, deviceClientId, pushSubscription.endpoint)) { + await pushSubscription.unsubscribe().catch(() => undefined); + pushSubscription = null; + endpointGivenUp = true; + // The other accounts' records point at the old endpoint: their turn comes too. + lastResync.clear(); + } if (!pushSubscription) { pushSubscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey, }); + if (endpointGivenUp) await moveOtherLogins(accountId, pushSubscription); } - const accountId = params.client.getAccountId(); - const deviceClientId = getOrCreateDeviceClientId(accountId); - await registerWithRelay({ relayBaseUrl, subscriptionId: deviceClientId, @@ -765,6 +877,12 @@ export async function enableWebPush( }, accountLabel: params.accountLabel, }); + localStorage.setItem(endpointKey(accountId), pushSubscription.endpoint); + await keepRelayRegistration(accountId, { + relay: relayBaseUrl, + subscriptionId: deviceClientId, + ...(params.accountLabel ? { accountLabel: params.accountLabel } : {}), + }, vapidPublicKey); // Reuse the JMAP-side PushSubscription if the server still has it, just // refreshing the expiry so it doesn't time out between sessions. With @@ -793,12 +911,16 @@ export async function enableWebPush( const storedServerId = localStorage.getItem(subIdKey); let replacedServerId: string | null = null; if (storedServerId) { - const match = existingSubs.find((s) => s.id === storedServerId); + // By device too: Stalwart gives the id of an expired subscription to the next + // one, which may be another device's. + const match = existingSubs.find((s) => s.id === storedServerId && s.deviceClientId === deviceClientId); if (match) { if (!params.forceRecreate) { const refreshed = await refreshSubscriptionExpires(params.client, match, emailPush); if (refreshed) { rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush); + // Unless it moved, which goes the other way, this is where it lives. + if (!localStorage.getItem(relayBaseKey(accountId))) localStorage.setItem(relayBaseKey(accountId), relayBaseUrl); return { subscriptionId: storedServerId }; } } @@ -829,7 +951,7 @@ export async function enableWebPush( // relay can't vouch for (a different relay, a non-Bulwark client, a // network blip) - is left untouched. for (const s of existingSubs) { - if (s.id === storedServerId) continue; + if (s.id === replacedServerId) continue; if (s.deviceClientId === deviceClientId) { await params.client.destroyPushSubscription(s.id).catch(() => undefined); continue; @@ -866,13 +988,27 @@ export async function enableWebPush( const serverAssignedId = created.result; rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); - const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId); - await params.client.verifyPushSubscription(serverAssignedId, verificationCode); + try { + const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId, () => + params.client.updatePushSubscription(serverAssignedId, { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS) }), + ); + await params.client.verifyPushSubscription(serverAssignedId, verificationCode); + } catch (err) { + // Unverified, it never delivers anything, takes up one of the account's + // places and stands in the way of the next verification. + await params.client.destroyPushSubscription(serverAssignedId).catch(() => undefined); + throw err; + } + const previousRelay = localStorage.getItem(relayBaseKey(accountId)); localStorage.setItem(subIdKey, serverAssignedId); localStorage.setItem(relayBaseKey(accountId), relayBaseUrl); if (replacedServerId) { await params.client.destroyPushSubscription(replacedServerId).catch(() => undefined); } + // Moved: the old relay would otherwise keep this browser's endpoint for 90 days. + if (previousRelay && previousRelay !== relayBaseUrl) { + await dropRelayRecord(previousRelay, deviceClientId); + } return { subscriptionId: serverAssignedId }; } @@ -882,46 +1018,76 @@ export interface DisableWebPushParams { relayBaseUrl?: string; } -// Best-effort teardown: clear the JMAP subscription, the relay mapping, and -// (only when no other accounts still need it) the browser-wide -// PushSubscription. Any single failure is swallowed so the user always ends -// up in a "disabled" state locally. -export async function disableWebPush(params: DisableWebPushParams): Promise { - const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); +/** Nothing is sent to a relay that may not be used, such as the built-in one in the no-third-party mode. */ +async function dropRelayRecord(relayBaseUrl: string, deviceClientId: string): Promise { + if (!relayBaseUrl || !relayAllowed(relayBaseUrl)) return true; + try { + const res = await fetch( + buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`), + { method: 'DELETE' }, + ); + return res.ok || res.status === 404; + } catch { + return false; + } +} + +// Teardown: clear the JMAP subscription, the relay mapping, and (only when no +// other accounts still need it) the browser-wide PushSubscription. A failed step +// doesn't stop the others, and the browser always ends up "disabled" locally, so +// a later page load won't bring push back. But a server or relay that couldn't be +// told keeps pinging this browser for days, so the caller hears about it at the end. +export function disableWebPush(params: DisableWebPushParams): Promise { + // Read now: a client signing out loses it while this waits its turn. const accountId = params.client.getAccountId(); + return oneTabAtATime(() => disableWebPushForThisTab(params, accountId)); +} + +async function disableWebPushForThisTab(params: DisableWebPushParams, accountId: string): Promise { + const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); const subIdKey = subscriptionIdKey(accountId); const devIdKey = deviceClientIdKey(accountId); const storedServerId = localStorage.getItem(subIdKey); const deviceClientId = localStorage.getItem(devIdKey); + const registeredWith = localStorage.getItem(relayBaseKey(accountId)); // Destroy every subscription the server holds for this device, not just the // id we happen to have recorded. A destroy that lost its round-trip, a failed // enable, or site data cleared between sessions can leave a registration this // client no longer tracks - and Stalwart keeps fanning StateChanges out to it // until it expires, so "disable" has to mean gone (#841). + const failed: string[] = []; const idsToDestroy = new Set(); - if (storedServerId) idsToDestroy.add(storedServerId); + // The stored id alone can have gone to another device since (Stalwart reuses them). + if (storedServerId && !deviceClientId) idsToDestroy.add(storedServerId); if (deviceClientId) { - const existingSubs = await params.client.listPushSubscriptions().catch(() => []); + const existingSubs = await params.client.listPushSubscriptions().catch(() => { + failed.push('listing the subscriptions'); + return []; + }); for (const s of existingSubs) { if (s.deviceClientId === deviceClientId) idsToDestroy.add(s.id); } } for (const id of idsToDestroy) { - await params.client.destroyPushSubscription(id).catch(() => undefined); + await params.client.destroyPushSubscription(id).catch(() => { + failed.push('destroying a subscription'); + }); } localStorage.removeItem(subIdKey); localStorage.removeItem(relayBaseKey(accountId)); + localStorage.removeItem(endpointKey(accountId)); + await forgetRelayRegistration(accountId); // Enabling again starts from the full emailPush map. localStorage.removeItem(emailPushRefusedKey(accountId)); - if (deviceClientId && relayBaseUrl && relayAllowed(relayBaseUrl)) { - await fetch( - buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`), - { method: 'DELETE' }, - ).catch(() => undefined); + if (deviceClientId) { + // The relay this device registered with can differ from the current choice. + for (const relay of new Set([registeredWith ?? relayBaseUrl, relayBaseUrl])) { + if (!(await dropRelayRecord(relay, deviceClientId))) failed.push('dropping the relay record'); + } } // Keep the deviceClientId around so a later re-enable for this account // reuses the same relay subscriptionId rather than scattering orphans. @@ -937,6 +1103,55 @@ export async function disableWebPush(params: DisableWebPushParams): Promise undefined); } + + if (failed.length > 0) { + throw new WebPushStepError('teardown', `Push is off in this browser, but ${failed.join(' and ')} failed`); + } +} + +/** + * A login is signing out or being removed: nothing for it should wake this + * browser any more, and it won't be there to refresh anything. Takes no relay + * from the settings: what counts is the one it registered with. + */ +export async function endWebPushForSignOut(client: IJMAPClient): Promise { + let accountId: string; + try { + accountId = client.getAccountId(); + } catch { + return; + } + if (!accountId || typeof localStorage === 'undefined' || !localStorage.getItem(subscriptionIdKey(accountId))) return; + const relayBaseUrl = localStorage.getItem(relayBaseKey(accountId)) ?? undefined; + await disableWebPush({ client, relayBaseUrl }).catch(() => undefined); +} + +/** + * Nobody stays signed in here. A login without a connection can't reach its mail + * server, but the relay needs no login: without the record its pings go nowhere. + * Whatever is left of the keys goes, so nothing keeps the browser subscription. + */ +export async function dropAllWebPushRegistrations(): Promise { + if (typeof localStorage === 'undefined') return; + const accounts: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key?.startsWith(SUBSCRIPTION_ID_PREFIX)) accounts.push(key.slice(SUBSCRIPTION_ID_PREFIX.length)); + } + if (accounts.length === 0) return; + for (const accountId of accounts) { + const relay = localStorage.getItem(relayBaseKey(accountId)); + const device = localStorage.getItem(deviceClientIdKey(accountId)); + if (relay && device) await dropRelayRecord(relay, device); + localStorage.removeItem(subscriptionIdKey(accountId)); + localStorage.removeItem(relayBaseKey(accountId)); + localStorage.removeItem(endpointKey(accountId)); + await forgetRelayRegistration(accountId); + } + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; + const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE).catch(() => null); + const sub = await registration?.pushManager.getSubscription().catch(() => null); + await sub?.unsubscribe().catch(() => undefined); } export interface PushDevice { @@ -1004,15 +1219,7 @@ export async function revokePushDevice(params: { } await params.client.destroyPushSubscription(params.device.id); - if (relayBaseUrl && relayAllowed(relayBaseUrl) && params.device.deviceClientId) { - await fetch( - buildRelayUrl( - relayBaseUrl, - `/api/push/register/${encodeURIComponent(params.device.deviceClientId)}`, - ), - { method: 'DELETE' }, - ).catch(() => undefined); - } + if (params.device.deviceClientId) await dropRelayRecord(relayBaseUrl, params.device.deviceClientId); } export async function isWebPushEnabled(accountId: string): Promise { @@ -1117,6 +1324,7 @@ async function runResync(accountId: string, params: ResyncWebPushParams): Promis if (!moved && !params.force && !(await resyncDue(accountId))) return false; await enableWebPush({ client: params.client, + onlyIfStillOn: true, forceRecreate: moved, relayBaseUrl: params.relayBaseUrl, accountLabel: params.accountLabel, diff --git a/locales/ar/common.json b/locales/ar/common.json index 7c9bf2825..ed9cbecb0 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "غير مفعّل على هذا الجهاز", "status_unsupported": "هذا المتصفح لا يدعم Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "الإشعارات في الخلفية غير متاحة في النسخة التجريبية.", "status_busy": "جارٍ العمل…", "enable": "تفعيل", "reenable": "إعادة التسجيل", @@ -1766,7 +1767,12 @@ "confirm_revoke_message_this": "سيتوقّف هذا الجهاز فورًا عن تلقّي الإشعارات في الخلفية.", "shared_mailboxes_no_push": "صناديق البريد التي يشاركها الآخرون معك لا ترسل إشعارات في الخلفية.", "error_forbidden": "لا يسمح خادم البريد بالإشعارات في الخلفية لهذا الحساب.", - "error_over_quota": "لدى هذا الحساب عدد كبير جدًا من تسجيلات الإشعارات الفورية على خادم البريد (الحد عادةً 15). ألغِ تسجيل الأجهزة التي لم تعد تستخدمها من إعدادات الإشعارات، ثم حاول مرة أخرى." + "error_over_quota": "لدى هذا الحساب عدد كبير جدًا من تسجيلات الإشعارات الفورية على خادم البريد (الحد عادةً 15). ألغِ تسجيل الأجهزة التي لم تعد تستخدمها من إعدادات الإشعارات، ثم حاول مرة أخرى.", + "error_blocked": "الإشعارات محظورة لهذا الموقع. اسمح بها في إعدادات الموقع في المتصفح، ثم حاول مرة أخرى.", + "error_not_granted": "لم يتم السماح بالإشعارات، لذا يبقى الإشعار الفوري متوقفًا.", + "error_verification_timeout": "لم يؤكد خادم البريد هذا الجهاز في الوقت المحدد، لذا يبقى الإشعار الفوري متوقفًا. حاول مرة أخرى بعد دقيقة.", + "error_relay": "لا يمكن الوصول إلى مرحّل الإشعارات الآن، لذا يبقى الإشعار الفوري متوقفًا. حاول لاحقًا.", + "error_teardown": "تم إيقاف الإشعارات الفورية في هذا المتصفح، لكن تعذر الوصول إلى خادم البريد أو المرحّل. قد تستمر الإشعارات في الوصول لبضعة أيام. لإيقافها أسرع، فعّل الإعدادات المتقدمة عندما تعود إلى الاتصال وألغِ هذا الجهاز ضمن الأجهزة المسجلة." }, "push_all": { "title": "All accounts", @@ -6873,14 +6879,40 @@ "blocked_minutes": "محاولات كثيرة جدًا. حاول بعد {count, plural, zero {# دقيقة} one {دقيقة واحدة} two {دقيقتين} few {# دقائق} many {# دقيقة} other {# دقيقة}}." }, "desktop_sign_in": { - "browser_button": "سجّل الدخول باستخدام المتصفح", - "browser_waiting": "أكمل تسجيل الدخول في المتصفح", - "browser_unavailable": "لا يتيح هذا الخادم تسجيل الدخول عبر المتصفح. استخدم كلمة المرور بدلًا من ذلك.", - "browser_failed": "لم ينجح تسجيل الدخول عبر المتصفح. حاول مرة أخرى.", "server_needed": "أدخل عنوان الخادم أولًا.", "server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.", "server_invalid": "عنوان الخادم هذا غير صالح." }, + "desktop_browser_sign_in": { + "button": "تسجيل الدخول عبر المتصفح…", + "waiting": "في انتظار المتصفح…", + "waiting_hint": "أكمل تسجيل الدخول هناك. يعمل مدير كلمات المرور لديك في المتصفح.", + "cancel": "إلغاء", + "or_here": "أو سجّل الدخول هنا", + "timed_out": "استغرق تسجيل الدخول في المتصفح وقتًا طويلًا. حاول مرة أخرى.", + "no_browser": "تعذر على Bulwark فتح المتصفح. حاول مرة أخرى أو سجّل الدخول هنا.", + "failed": "تعذر تسجيل الدخول عبر المتصفح. حاول مرة أخرى.", + "handoff_banner": "طلب برنامج على هذا الكمبيوتر تسجيل الدخول هذا لتطبيق Bulwark. يبقى هذا المتصفح خارج Bulwark. إذا لم تبدأ ذلك بنفسك، فأغلق علامة التبويب هذه.", + "consent_title": "هل تريد تسجيل الدخول إلى تطبيق Bulwark؟", + "consent_description": "يطلب برنامج على هذا الكمبيوتر، على المنفذ {port}، تسجيل دخول {username}. تابع فقط إذا كنت قد ضغطت للتو على «تسجيل الدخول عبر المتصفح…» في تطبيق Bulwark هنا. وإلا فقد يحاول شخص ما الوصول إلى حسابك: اختر ليس الآن.", + "consent_confirm": "تسجيل الدخول إلى التطبيق", + "consent_cancel": "ليس الآن", + "page_done": "يمكنك إغلاق علامة التبويب هذه والعودة إلى Bulwark.", + "page_failed": "تعذر تسجيل الدخول. عد إلى Bulwark وحاول مرة أخرى.", + "page_link_used": "يعمل هذا الرابط مرة واحدة فقط. عد إلى Bulwark وابدأ من جديد.", + "page_title": "تسجيل الدخول إلى {server}", + "page_intro": "هذه الصفحة من تطبيق Bulwark على هذا الكمبيوتر. تذهب كلمة مرورك إلى {server} فقط.", + "page_username": "عنوان البريد الإلكتروني أو اسم المستخدم", + "page_password": "كلمة المرور", + "page_code": "رمز المصادقة الثنائية", + "page_submit": "تسجيل الدخول", + "page_wrong_password": "لم يقبل الخادم اسم المستخدم وكلمة المرور هذين.", + "page_code_needed": "يحتاج هذا الحساب أيضًا إلى رمز المصادقة الثنائية. أدخل كلمة المرور مرة أخرى والرمز.", + "page_no_answer": "تعذر على Bulwark تسجيل الدخول. تحقق من إمكانية الوصول إلى الخادم وحاول مرة أخرى.", + "declined": "رفضت في المتصفح، لذا لم يتم تسجيل الدخول.", + "too_many": "محاولات تسجيل دخول كثيرة جدًا من هذه الشبكة. انتظر بضع دقائق وحاول مرة أخرى.", + "page_declined": "لم يتم تسليم أي شيء. يمكنك إغلاق علامة التبويب هذه." + }, "desktop_updates": { "title": "التحديثات", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "تعذّر نسخ الملف.", "reply_lost": "تعذّر الاحتفاظ بالرد الذي كتبته. افتح الرسالة وأجب من هناك." }, + "desktopEdit": { + "edit": "تعديل", + "edit_named": "تعديل {name}", + "opened": "{name} مفتوح في برنامجه. احفظ تغييراتك هناك، وسيُطلب منك تحديد ما يجب فعله بها.", + "offers_label": "المرفقات المعدّلة", + "changed_title": "تم تغيير {name}", + "changed_body": "حفظت تغييرات في برنامج آخر. ماذا تريد أن تفعل بها؟", + "reply": "إرفاق برد", + "new_message": "إرفاق برسالة جديدة", + "open_draft": "فتح المسودة", + "save_copy": "حفظ نسخة…", + "discard": "تجاهل التعديلات…", + "discard_title": "هل تريد تجاهل تعديلاتك على {name}؟", + "discard_detail": "تُحذف النسخة المعدّلة. ولا يتغير المرفق الأصلي.", + "discard_confirm": "تجاهل", + "cancel": "إلغاء", + "update": "تحديث المرفق", + "no_app": "لا يوجد برنامج على هذا الكمبيوتر يمكنه فتح هذا الملف.", + "blocked_type": "لا يمكن فتح هذا النوع من الملفات للتعديل، لأنه قد يعمل كبرنامج.", + "blocked_policy": "لا تسمح مؤسستك بحفظ المرفقات على هذا الجهاز.", + "removed_policy": "لم تعد مؤسستك تسمح بذلك، لذا حُذفت النسخة المعدّلة من {name}.", + "too_large": "هذا الملف كبير جدًا بحيث لا يمكن فتحه للتعديل.", + "too_many": "هناك ملفات كثيرة جدًا مفتوحة للتعديل. انتهِ من بعضها أولًا.", + "failed": "تعذر فتح الملف للتعديل.", + "read_busy": "ما زال برنامج آخر يستخدم {name}. أغلقه هناك وحاول مرة أخرى.", + "read_failed": "تعذرت قراءة النسخة المعدّلة من {name}.", + "send_stale_title": "يحتوي {name} على تعديلات ليست في هذه الرسالة", + "send_stale_detail": "إذا أرسلت الآن، فستُرسل الرسالة بالإصدار الأقدم. تبقى تعديلاتك على هذا الكمبيوتر حتى 30 يومًا إلى أن تقرر ما تفعله بها.", + "send_stale_send": "إرسال على أي حال", + "send_stale_back": "رجوع", + "signout_title": "هل تريد تسجيل الخروج وحذف تعديلاتك؟", + "signout_confirm": "تسجيل الخروج", + "lock_note": "تبقى الملفات التي فتحتها باستخدام «تعديل» مفتوحة في برنامجها أثناء قفل التطبيق، وتظل قابلة للقراءة على هذا الكمبيوتر. ولا يمكن فتح ملفات جديدة حتى تفتح القفل.", + "changed_title_hidden": "تم حفظ مرفق معدّل", + "last_chance": "لم يُستخدم منذ 30 يومًا. ستُحذف هذه النسخة غدًا.", + "held_named": "{names}: ما زال مفتوحًا في برنامج آخر، لذا لا يمكن حذف النسخة بعد. أغلقه هناك وستُحذف.", + "held_unnamed": "نسخة مرفق ما زالت مفتوحة في برنامج آخر ولا يمكن حذفها بعد. أغلقها هناك وستُحذف.", + "removed_policy_held": "لم تعد مؤسستك تسمح بذلك. ما زال {name} مفتوحًا في برنامج آخر، لذا لا يمكن حذف نسخته إلا بعد إغلاقه هناك.", + "removed_policy_hidden": "لم تعد مؤسستك تسمح بتعديل المرفقات، لذا أُزيلت نسخة معدّلة.", + "outside_note": "لا يمكن لهذا التطبيق مسح ملفات الاسترداد وقائمة الملفات الأخيرة الخاصة بذلك البرنامج.", + "clear_title": "هل تريد حذف مرفقاتك المعدّلة؟", + "clear_confirm": "حذف", + "signout_changed": "لم تُرفق أو تُحفظ بعد، وتُحذف الآن: {names}.", + "signout_held": "ما زالت مفتوحة في برنامج آخر، لذا لن تُحذف إلا بعد إغلاقها هناك: {names}." + }, "desktopQuickCompose": { "label": "كتابة سريعة", "menu": "كتابة سريعة", diff --git a/locales/ca/common.json b/locales/ca/common.json index 9c2f3e529..5e3d4916d 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "No activat en aquest dispositiu", "status_unsupported": "Aquest navegador no admet Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Les notificacions en segon pla no estan disponibles a la demo.", "status_busy": "Treballant…", "enable": "Activa", "reenable": "Torna a registrar", @@ -1766,7 +1767,12 @@ "confirm_revoke_message_this": "Aquest dispositiu deixarà de rebre notificacions en segon pla immediatament.", "shared_mailboxes_no_push": "Les bústies que altres usuaris comparteixen amb vós no envien notificacions en segon pla.", "error_forbidden": "El servidor de correu no permet notificacions en segon pla per a aquest compte.", - "error_over_quota": "Aquest compte té massa registres push al servidor de correu (el límit sol ser 15). Revoqueu els dispositius que ja no feu servir a la configuració de notificacions i torneu-ho a provar." + "error_over_quota": "Aquest compte té massa registres push al servidor de correu (el límit sol ser 15). Revoqueu els dispositius que ja no feu servir a la configuració de notificacions i torneu-ho a provar.", + "error_blocked": "Les notificacions estan bloquejades per a aquest lloc. Permet-les a la configuració del lloc del navegador i torna-ho a provar.", + "error_not_granted": "No s'han permès les notificacions, així que les notificacions push continuen desactivades.", + "error_verification_timeout": "El servidor de correu no ha confirmat aquest dispositiu a temps, així que les notificacions push continuen desactivades. Torna-ho a provar d'aquí a un minut.", + "error_relay": "Ara mateix no es pot accedir al repetidor de notificacions, així que les notificacions push continuen desactivades. Torna-ho a provar més tard.", + "error_teardown": "Les notificacions push estan desactivades en aquest navegador, però no s'ha pogut accedir al servidor de correu o al repetidor. Encara poden arribar notificacions durant uns dies. Per aturar-les abans, activa la configuració avançada quan tornis a tenir connexió i revoca aquest dispositiu a Dispositius registrats." }, "push_all": { "title": "All accounts", @@ -6873,14 +6879,40 @@ "blocked_minutes": "Massa intents. Torneu-ho a provar d'aquí a {count, plural, one {# minut} other {# minuts}}." }, "desktop_sign_in": { - "browser_button": "Inicia la sessió amb el navegador", - "browser_waiting": "Acaba d'iniciar la sessió al navegador", - "browser_unavailable": "Aquest servidor no permet iniciar la sessió amb el navegador. Fes servir la contrasenya.", - "browser_failed": "No s'ha pogut iniciar la sessió amb el navegador. Torna-ho a provar.", "server_needed": "Primer escriu l'adreça del servidor.", "server_declined": "No has permès aquest servidor, així que no s'hi ha enviat res.", "server_invalid": "Aquesta adreça de servidor no és vàlida." }, + "desktop_browser_sign_in": { + "button": "Inicia la sessió amb el navegador…", + "waiting": "S’està esperant el navegador…", + "waiting_hint": "Acaba d’iniciar la sessió allà. El teu gestor de contrasenyes funciona al navegador.", + "cancel": "Cancel·la", + "or_here": "o inicia la sessió aquí", + "timed_out": "L’inici de sessió al navegador ha trigat massa. Torna-ho a provar.", + "no_browser": "Bulwark no ha pogut obrir el navegador. Torna-ho a provar o inicia la sessió aquí.", + "failed": "No s’ha pogut iniciar la sessió a través del navegador. Torna-ho a provar.", + "handoff_banner": "Un programa d’aquest ordinador ha demanat aquest inici de sessió per a l’aplicació Bulwark. Aquest navegador continua sense sessió a Bulwark. Si no ho has començat tu, tanca aquesta pestanya.", + "consent_title": "Vols iniciar la sessió a l’aplicació Bulwark?", + "consent_description": "Un programa d’aquest ordinador, al port {port}, demana l’inici de sessió de {username}. Continua només si acabes de prémer «Inicia la sessió amb el navegador…» a l’aplicació Bulwark d’aquí. Si no, potser algú intenta entrar al teu compte: tria Ara no.", + "consent_confirm": "Inicia la sessió a l’aplicació", + "consent_cancel": "Ara no", + "page_done": "Pots tancar aquesta pestanya i tornar a Bulwark.", + "page_failed": "No s’ha pogut iniciar la sessió. Torna a Bulwark i torna-ho a provar.", + "page_link_used": "Aquest enllaç només funciona una vegada. Torna a Bulwark i comença de nou.", + "page_title": "Inicia la sessió a {server}", + "page_intro": "Aquesta pàgina ve de l’aplicació Bulwark d’aquest ordinador. La teva contrasenya va a {server} i enlloc més.", + "page_username": "Adreça electrònica o nom d’usuari", + "page_password": "Contrasenya", + "page_code": "Codi de dos factors", + "page_submit": "Inicia la sessió", + "page_wrong_password": "El servidor no ha acceptat aquest nom d’usuari i aquesta contrasenya.", + "page_code_needed": "Aquest compte també necessita un codi de dos factors. Torna a escriure la contrasenya i el codi.", + "page_no_answer": "Bulwark no ha pogut iniciar la sessió. Comprova que el servidor sigui accessible i torna-ho a provar.", + "declined": "Has dit que no al navegador, així que no s’ha iniciat cap sessió.", + "too_many": "Massa intents d’inici de sessió des d’aquesta xarxa. Espera uns minuts i torna-ho a provar.", + "page_declined": "No s’ha lliurat res. Pots tancar aquesta pestanya." + }, "desktop_updates": { "title": "Actualitzacions", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "No s'ha pogut copiar el fitxer.", "reply_lost": "No s'ha pogut conservar la resposta que heu escrit. Obriu el missatge i responeu des d'allà." }, + "desktopEdit": { + "edit": "Edita", + "edit_named": "Edita {name}", + "opened": "{name} és obert al seu programa. Deseu-hi els canvis i se us preguntarà què fer-ne.", + "offers_label": "Adjunts editats", + "changed_title": "{name} ha canviat", + "changed_body": "Heu desat canvis en un altre programa. Què voleu fer-ne?", + "reply": "Adjunta a una resposta", + "new_message": "Adjunta a un missatge nou", + "open_draft": "Obre l’esborrany", + "save_copy": "Desa una còpia…", + "discard": "Descarta els canvis…", + "discard_title": "Voleu descartar els canvis a {name}?", + "discard_detail": "S’elimina la còpia editada. L’adjunt original no canvia.", + "discard_confirm": "Descarta", + "cancel": "Cancel·la", + "update": "Actualitza l’adjunt", + "no_app": "Cap programa d’aquest ordinador pot obrir aquest fitxer.", + "blocked_type": "Aquest tipus de fitxer no es pot obrir per editar-lo, perquè es podria executar com un programa.", + "blocked_policy": "La vostra organització no permet desar adjunts en aquest dispositiu.", + "removed_policy": "La vostra organització ja no ho permet, així que s’ha eliminat la còpia editada de {name}.", + "too_large": "Aquest fitxer és massa gran per obrir-lo i editar-lo.", + "too_many": "Hi ha massa fitxers oberts per editar. Primer acabeu-ne alguns.", + "failed": "No s’ha pogut obrir el fitxer per editar-lo.", + "read_busy": "Un altre programa encara fa servir {name}. Tanqueu-lo allà i torneu-ho a provar.", + "read_failed": "No s’ha pogut llegir la còpia editada de {name}.", + "send_stale_title": "{name} té canvis que no són en aquest missatge", + "send_stale_detail": "Si l’envieu ara, el missatge surt amb la versió anterior. Els canvis es queden en aquest ordinador fins a 30 dies, fins que decidiu què fer-ne.", + "send_stale_send": "Envia igualment", + "send_stale_back": "Torna", + "signout_title": "Voleu tancar la sessió i eliminar els canvis?", + "signout_confirm": "Tanca la sessió", + "lock_note": "Els fitxers oberts amb Edita continuen oberts al seu programa mentre l’aplicació està bloquejada, i continuen sent llegibles en aquest ordinador. No se’n poden obrir de nous fins que la desbloquegeu.", + "changed_title_hidden": "S’ha desat un adjunt editat", + "last_chance": "Sense fer servir durant 30 dies. Aquesta còpia s’elimina demà.", + "held_named": "{names}: encara obert en un altre programa, així que la còpia encara no es pot eliminar. Tanqueu-lo allà i s’eliminarà.", + "held_unnamed": "La còpia d’un adjunt encara és oberta en un altre programa i encara no es pot eliminar. Tanqueu-la allà i s’eliminarà.", + "removed_policy_held": "La vostra organització ja no ho permet. {name} encara és obert en un altre programa, així que la còpia no es pot eliminar fins que el tanqueu allà.", + "removed_policy_hidden": "La vostra organització ja no permet editar adjunts, així que s’ha tret una còpia editada.", + "outside_note": "Els fitxers de recuperació i la llista de fitxers recents d’aquell programa queden fora de l’abast d’aquesta aplicació.", + "clear_title": "Voleu eliminar els adjunts editats?", + "clear_confirm": "Elimina", + "signout_changed": "Encara sense adjuntar ni desar, i eliminats ara: {names}.", + "signout_held": "Encara oberts en un altre programa, així que no s’eliminen fins que els tanqueu allà: {names}." + }, "desktopQuickCompose": { "label": "Redacció ràpida", "menu": "Redacció ràpida", diff --git a/locales/cs/common.json b/locales/cs/common.json index d4f12d33d..ace3e4105 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Není povoleno na tomto zařízení", "status_unsupported": "Tento prohlížeč nepodporuje Web Push", "status_lite": "Web Push není v Bulwark Lite k dispozici; vyžaduje server Bulwark.", + "status_demo": "Oznámení na pozadí nejsou v ukázce k dispozici.", "title": "Oznámení na pozadí", "devices_title": "Registrovaná zařízení", "devices_desc": "Všechna zařízení registrovaná pro příjem oznamení na pozadí u tohoto účtu. Odvolání okamžitě zastaví jejich příjem.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "Toto zařízení okamžitě přestane přijímat oznámení na pozadí.", "shared_mailboxes_no_push": "Poštovní schránky, které s vámi sdílejí ostatní, neodesílají oznámení na pozadí.", "error_forbidden": "Poštovní server pro tento účet nepovoluje oznámení na pozadí.", - "error_over_quota": "Tento účet má na poštovním serveru příliš mnoho registrací push (limit je obvykle 15). V nastavení oznámení odvolejte zařízení, která už nepoužíváte, a zkuste to znovu." + "error_over_quota": "Tento účet má na poštovním serveru příliš mnoho registrací push (limit je obvykle 15). V nastavení oznámení odvolejte zařízení, která už nepoužíváte, a zkuste to znovu.", + "error_blocked": "Oznámení jsou pro tento web blokována. Povolte je v nastavení webu v prohlížeči a zkuste to znovu.", + "error_not_granted": "Oznámení nebyla povolena, takže push zůstává vypnutý.", + "error_verification_timeout": "Poštovní server toto zařízení včas nepotvrdil, takže push zůstává vypnutý. Zkuste to znovu za minutu.", + "error_relay": "Push relay teď není dostupné, takže push zůstává vypnutý. Zkuste to později.", + "error_teardown": "Push je v tomto prohlížeči vypnutý, ale poštovní server nebo relay nebyly dostupné. Oznámení mohou ještě několik dní přicházet. Chcete-li je zastavit dříve, až budete online, zapněte pokročilá nastavení a odvolejte toto zařízení v části Registrovaná zařízení." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Příliš mnoho pokusů. Zkuste to za {count, plural, one {# minutu} few {# minuty} many {# minuty} other {# minut}}." }, "desktop_sign_in": { - "browser_button": "Přihlásit se v prohlížeči", - "browser_waiting": "Dokončete přihlášení v prohlížeči", - "browser_unavailable": "Tento server přihlášení přes prohlížeč nenabízí. Použijte heslo.", - "browser_failed": "Přihlášení přes prohlížeč se nezdařilo. Zkuste to znovu.", "server_needed": "Nejdřív zadejte adresu serveru.", "server_declined": "Tento server jste nepovolili, proto se na něj nic neodeslalo.", "server_invalid": "Tato adresa serveru není platná." }, + "desktop_browser_sign_in": { + "button": "Přihlásit se v prohlížeči…", + "waiting": "Čekání na prohlížeč…", + "waiting_hint": "Dokončete přihlášení tam. Váš správce hesel funguje v prohlížeči.", + "cancel": "Zrušit", + "or_here": "nebo se přihlaste zde", + "timed_out": "Přihlášení v prohlížeči trvalo příliš dlouho. Zkuste to znovu.", + "no_browser": "Bulwark nemohl otevřít prohlížeč. Zkuste to znovu nebo se přihlaste zde.", + "failed": "Přihlášení přes prohlížeč se nezdařilo. Zkuste to znovu.", + "handoff_banner": "Program v tomto počítači požádal o toto přihlášení pro aplikaci Bulwark. Tento prohlížeč zůstane z Bulwarku odhlášený. Pokud jste to nespustili vy, zavřete tuto kartu.", + "consent_title": "Přihlásit se do aplikace Bulwark?", + "consent_description": "Program v tomto počítači, na portu {port}, žádá o přihlášení pro {username}. Pokračujte, jen pokud jste právě klikli na „Přihlásit se v prohlížeči…“ v aplikaci Bulwark v tomto počítači. Pokud ne, možná se někdo snaží dostat do vašeho účtu: zvolte Teď ne.", + "consent_confirm": "Přihlásit se do aplikace", + "consent_cancel": "Teď ne", + "page_done": "Tuto kartu můžete zavřít a vrátit se do Bulwarku.", + "page_failed": "Přihlášení se nezdařilo. Vraťte se do Bulwarku a zkuste to znovu.", + "page_link_used": "Tento odkaz funguje jen jednou. Vraťte se do Bulwarku a začněte znovu.", + "page_title": "Přihlásit se k {server}", + "page_intro": "Tato stránka pochází z aplikace Bulwark v tomto počítači. Vaše heslo jde na {server} a nikam jinam.", + "page_username": "E-mailová adresa nebo uživatelské jméno", + "page_password": "Heslo", + "page_code": "Dvoufázový kód", + "page_submit": "Přihlásit se", + "page_wrong_password": "Server toto uživatelské jméno a heslo nepřijal.", + "page_code_needed": "Tento účet potřebuje také dvoufázový kód. Zadejte znovu heslo a kód.", + "page_no_answer": "Bulwark se nemohl přihlásit. Zkontrolujte, zda je server dostupný, a zkuste to znovu.", + "declined": "V prohlížeči jste odmítli, takže k přihlášení nedošlo.", + "too_many": "Příliš mnoho pokusů o přihlášení z této sítě. Počkejte několik minut a zkuste to znovu.", + "page_declined": "Nic nebylo předáno. Tuto kartu můžete zavřít." + }, "desktop_updates": { "title": "Aktualizace", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Soubor se nepodařilo zkopírovat.", "reply_lost": "Napsanou odpověď se nepodařilo zachovat. Otevřete zprávu a odpovězte v ní." }, + "desktopEdit": { + "edit": "Upravit", + "edit_named": "Upravit {name}", + "opened": "{name} je otevřený ve svém programu. Uložte tam změny a pak se vás zeptáme, co s nimi udělat.", + "offers_label": "Upravené přílohy", + "changed_title": "{name} se změnil", + "changed_body": "Uložili jste změny v jiném programu. Co s nimi chcete udělat?", + "reply": "Připojit k odpovědi", + "new_message": "Připojit k nové zprávě", + "open_draft": "Otevřít koncept", + "save_copy": "Uložit kopii…", + "discard": "Zahodit změny…", + "discard_title": "Zahodit změny v {name}?", + "discard_detail": "Upravená kopie se smaže. Původní příloha se nezmění.", + "discard_confirm": "Zahodit", + "cancel": "Zrušit", + "update": "Aktualizovat přílohu", + "no_app": "Tento soubor nedokáže otevřít žádný program na tomto počítači.", + "blocked_type": "Tento typ souboru nelze otevřít k úpravám, protože by se mohl spustit jako program.", + "blocked_policy": "Vaše organizace nepovoluje ukládat přílohy na toto zařízení.", + "removed_policy": "Vaše organizace už to nepovoluje, proto byla upravená kopie {name} smazána.", + "too_large": "Tento soubor je příliš velký, aby se dal otevřít k úpravám.", + "too_many": "K úpravám je otevřeno příliš mnoho souborů. Některé nejdřív dokončete.", + "failed": "Soubor se nepodařilo otevřít k úpravám.", + "read_busy": "{name} stále používá jiný program. Zavřete ho tam a zkuste to znovu.", + "read_failed": "Upravenou kopii {name} se nepodařilo přečíst.", + "send_stale_title": "{name} má změny, které v této zprávě nejsou", + "send_stale_detail": "Když teď odešlete, zpráva odejde se starší verzí. Vaše změny zůstanou na tomto počítači až 30 dní, dokud se nerozhodnete, co s nimi.", + "send_stale_send": "Přesto odeslat", + "send_stale_back": "Zpět", + "signout_title": "Odhlásit se a smazat změny?", + "signout_confirm": "Odhlásit se", + "lock_note": "Soubory otevřené přes Upravit zůstanou otevřené ve svém programu, i když je aplikace zamčená, a zůstanou na tomto počítači čitelné. Nové se otevřou až po odemčení.", + "changed_title_hidden": "Upravená příloha byla uložena", + "last_chance": "Nepoužito 30 dní. Tato kopie bude zítra smazána.", + "held_named": "{names}: stále otevřeno v jiném programu, takže kopii zatím nelze smazat. Zavřete to tam a smaže se.", + "held_unnamed": "Kopie přílohy je stále otevřená v jiném programu a zatím ji nelze smazat. Zavřete ji tam a smaže se.", + "removed_policy_held": "Vaše organizace už to nepovoluje. {name} je stále otevřený v jiném programu, takže kopii nelze smazat, dokud ho tam nezavřete.", + "removed_policy_hidden": "Vaše organizace už nepovoluje upravovat přílohy, proto byla upravená kopie odstraněna.", + "outside_note": "Soubory pro obnovení a seznam nedávných souborů toho programu tato aplikace vymazat nemůže.", + "clear_title": "Smazat upravené přílohy?", + "clear_confirm": "Smazat", + "signout_changed": "Zatím nepřipojené ani neuložené, a teď smazané: {names}.", + "signout_held": "Stále otevřené v jiném programu, takže se smažou až po zavření: {names}." + }, "desktopQuickCompose": { "label": "Rychlá zpráva", "menu": "Rychlá zpráva", diff --git a/locales/da/common.json b/locales/da/common.json index 3b9943a02..b473c8379 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Ikke aktiveret på denne enhed", "status_unsupported": "Denne browser understøtter ikke Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Baggrundsnotifikationer er ikke tilgængelige i demoen.", "status_busy": "Arbejder…", "enable": "Aktivér", "reenable": "Genregistrér", @@ -1766,7 +1767,12 @@ "confirm_revoke_message_this": "Denne enhed stopper med at modtage baggrundsnotifikationer med det samme.", "shared_mailboxes_no_push": "Postkasser, som andre deler med dig, sender ikke baggrundsnotifikationer.", "error_forbidden": "Mailserveren tillader ikke baggrundsnotifikationer for denne konto.", - "error_over_quota": "Denne konto har for mange push-registreringer på mailserveren (grænsen er normalt 15). Tilbagekald enheder, du ikke længere bruger, i notifikationsindstillingerne, og prøv igen." + "error_over_quota": "Denne konto har for mange push-registreringer på mailserveren (grænsen er normalt 15). Tilbagekald enheder, du ikke længere bruger, i notifikationsindstillingerne, og prøv igen.", + "error_blocked": "Notifikationer er blokeret for dette websted. Tillad dem i browserens webstedsindstillinger, og prøv igen.", + "error_not_granted": "Notifikationer blev ikke tilladt, så push forbliver slået fra.", + "error_verification_timeout": "Mailserveren bekræftede ikke denne enhed i tide, så push forbliver slået fra. Prøv igen om et minut.", + "error_relay": "Push-relayet kan ikke nås lige nu, så push forbliver slået fra. Prøv igen senere.", + "error_teardown": "Push er slået fra i denne browser, men mailserveren eller relayet kunne ikke nås. Der kan stadig komme notifikationer i nogle dage. Vil du stoppe dem før, så slå avancerede indstillinger til, når du er online igen, og tilbagekald denne enhed under Registrerede enheder." }, "push_all": { "title": "All accounts", @@ -6873,14 +6879,40 @@ "blocked_minutes": "For mange forsøg. Prøv igen om {count, plural, one {# minut} other {# minutter}}." }, "desktop_sign_in": { - "browser_button": "Log ind med din browser", - "browser_waiting": "Gør login færdigt i din browser", - "browser_unavailable": "Denne server tilbyder ikke login via browser. Brug din adgangskode i stedet.", - "browser_failed": "Login via browseren lykkedes ikke. Prøv igen.", "server_needed": "Angiv først serverens adresse.", "server_declined": "Du tillod ikke denne server, så der blev ikke sendt noget til den.", "server_invalid": "Denne serveradresse er ikke gyldig." }, + "desktop_browser_sign_in": { + "button": "Log ind med din browser…", + "waiting": "Venter på din browser…", + "waiting_hint": "Afslut login der. Din adgangskodeadministrator virker i din browser.", + "cancel": "Annuller", + "or_here": "eller log ind her", + "timed_out": "Login i browseren tog for lang tid. Prøv igen.", + "no_browser": "Bulwark kunne ikke åbne din browser. Prøv igen, eller log ind her.", + "failed": "Login via din browser lykkedes ikke. Prøv igen.", + "handoff_banner": "Et program på denne computer har bedt om dette login til Bulwark-appen. Denne browser forbliver logget ud af Bulwark. Hvis du ikke har startet dette, så luk denne fane.", + "consent_title": "Log ind i Bulwark-appen?", + "consent_description": "Et program på denne computer, på port {port}, beder om login for {username}. Fortsæt kun, hvis du lige har trykket på \"Log ind med din browser…\" i Bulwark-appen her. Hvis ikke, prøver nogen måske at komme ind på din konto: vælg Ikke nu.", + "consent_confirm": "Log ind i appen", + "consent_cancel": "Ikke nu", + "page_done": "Du kan lukke denne fane og gå tilbage til Bulwark.", + "page_failed": "Login lykkedes ikke. Gå tilbage til Bulwark, og prøv igen.", + "page_link_used": "Dette link virker kun én gang. Gå tilbage til Bulwark, og start forfra.", + "page_title": "Log ind på {server}", + "page_intro": "Denne side kommer fra Bulwark-appen på denne computer. Din adgangskode går til {server} og ingen andre steder.", + "page_username": "E-mailadresse eller brugernavn", + "page_password": "Adgangskode", + "page_code": "Tofaktorkode", + "page_submit": "Log ind", + "page_wrong_password": "Serveren accepterede ikke dette brugernavn og denne adgangskode.", + "page_code_needed": "Denne konto skal også bruge en tofaktorkode. Indtast din adgangskode igen og koden.", + "page_no_answer": "Bulwark kunne ikke logge ind. Tjek, at serveren kan nås, og prøv igen.", + "declined": "Du sagde nej i din browser, så der blev ikke logget ind.", + "too_many": "For mange loginforsøg fra dette netværk. Vent et par minutter, og prøv igen.", + "page_declined": "Intet blev overdraget. Du kan lukke denne fane." + }, "desktop_updates": { "title": "Opdateringer", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Filen kunne ikke kopieres.", "reply_lost": "Dit skrevne svar kunne ikke gemmes. Åbn beskeden, og svar der." }, + "desktopEdit": { + "edit": "Rediger", + "edit_named": "Rediger {name}", + "opened": "{name} er åben i sit program. Gem dine ændringer der, så bliver du spurgt, hvad der skal ske med dem.", + "offers_label": "Redigerede vedhæftninger", + "changed_title": "{name} blev ændret", + "changed_body": "Du har gemt ændringer i et andet program. Hvad skal der ske med dem?", + "reply": "Vedhæft til et svar", + "new_message": "Vedhæft til en ny besked", + "open_draft": "Åbn kladde", + "save_copy": "Gem en kopi…", + "discard": "Kassér ændringer…", + "discard_title": "Kassér dine ændringer i {name}?", + "discard_detail": "Den redigerede kopi slettes. Den oprindelige vedhæftning ændres ikke.", + "discard_confirm": "Kassér", + "cancel": "Annuller", + "update": "Opdater vedhæftning", + "no_app": "Intet program på denne computer kan åbne filen.", + "blocked_type": "Denne filtype kan ikke åbnes til redigering, fordi den kan køre som et program.", + "blocked_policy": "Din organisation tillader ikke at gemme vedhæftninger på denne enhed.", + "removed_policy": "Din organisation tillader det ikke længere, så den redigerede kopi af {name} blev slettet.", + "too_large": "Filen er for stor til at åbne til redigering.", + "too_many": "Der er for mange filer åbne til redigering. Gør nogle af dem færdige først.", + "failed": "Filen kunne ikke åbnes til redigering.", + "read_busy": "Et andet program bruger stadig {name}. Luk den der, og prøv igen.", + "read_failed": "Den redigerede kopi af {name} kunne ikke læses.", + "send_stale_title": "{name} har ændringer, der ikke er i denne besked", + "send_stale_detail": "Hvis du sender nu, går beskeden af sted med den ældre version. Dine ændringer bliver på denne computer i op til 30 dage, indtil du beslutter, hvad der skal ske med dem.", + "send_stale_send": "Send alligevel", + "send_stale_back": "Tilbage", + "signout_title": "Log ud og slet dine ændringer?", + "signout_confirm": "Log ud", + "lock_note": "Filer, du har åbnet med Rediger, forbliver åbne i deres program, mens appen er låst, og kan stadig læses på denne computer. Nye kan først åbnes, når du låser op.", + "changed_title_hidden": "En redigeret vedhæftning blev gemt", + "last_chance": "Ikke brugt i 30 dage. Denne kopi slettes i morgen.", + "held_named": "{names}: stadig åben i et andet program, så kopien kan ikke slettes endnu. Luk den der, så slettes den.", + "held_unnamed": "Kopien af en vedhæftning er stadig åben i et andet program og kan ikke slettes endnu. Luk den der, så slettes den.", + "removed_policy_held": "Din organisation tillader det ikke længere. {name} er stadig åben i et andet program, så kopien kan først slettes, når du lukker den der.", + "removed_policy_hidden": "Din organisation tillader ikke længere redigering af vedhæftninger, så en redigeret kopi blev fjernet.", + "outside_note": "Programmets egne gendannelsesfiler og liste over seneste filer kan denne app ikke rydde.", + "clear_title": "Slet dine redigerede vedhæftninger?", + "clear_confirm": "Slet", + "signout_changed": "Endnu ikke vedhæftet eller gemt, og slettet nu: {names}.", + "signout_held": "Stadig åbne i et andet program, så de slettes først, når du lukker dem der: {names}." + }, "desktopQuickCompose": { "label": "Hurtig skrivning", "menu": "Hurtig skrivning", diff --git a/locales/de/common.json b/locales/de/common.json index 3a51bd665..9cb2097f7 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Auf diesem Gerät nicht aktiviert", "status_unsupported": "Dieser Browser unterstützt Web Push nicht", "status_lite": "Web Push ist in Bulwark Lite nicht verfügbar; es benötigt den Bulwark-Server.", + "status_demo": "Hintergrundbenachrichtigungen sind in der Demo nicht verfügbar.", "title": "Hintergrundbenachrichtigungen", "devices_title": "Registrierte Geräte", "devices_desc": "Alle Geräte, die Hintergrundbenachrichtigungen für dieses Konto erhalten. Wird eines widerrufen, empfängt es sofort keine mehr.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "Dieses Gerät empfängt sofort keine Hintergrundbenachrichtigungen mehr.", "shared_mailboxes_no_push": "Postfächer, die andere mit Ihnen teilen, senden keine Hintergrundbenachrichtigungen.", "error_forbidden": "Der Mailserver erlaubt für dieses Konto keine Hintergrundbenachrichtigungen.", - "error_over_quota": "Für dieses Konto gibt es auf dem Mailserver zu viele Push-Registrierungen (meist sind 15 erlaubt). Widerrufen Sie Geräte, die Sie nicht mehr nutzen, in den Benachrichtigungseinstellungen und versuchen Sie es erneut." + "error_over_quota": "Für dieses Konto gibt es auf dem Mailserver zu viele Push-Registrierungen (meist sind 15 erlaubt). Widerrufen Sie Geräte, die Sie nicht mehr nutzen, in den Benachrichtigungseinstellungen und versuchen Sie es erneut.", + "error_blocked": "Benachrichtigungen sind für diese Website blockiert. Erlauben Sie sie in den Website-Einstellungen Ihres Browsers und versuchen Sie es dann erneut.", + "error_not_granted": "Benachrichtigungen wurden nicht erlaubt, Push bleibt deshalb aus.", + "error_verification_timeout": "Der Mailserver hat dieses Gerät nicht rechtzeitig bestätigt, Push bleibt deshalb aus. Versuchen Sie es in einer Minute erneut.", + "error_relay": "Das Push-Relay ist gerade nicht erreichbar, Push bleibt deshalb aus. Versuchen Sie es später erneut.", + "error_teardown": "Push ist in diesem Browser aus, aber der Mailserver oder das Relay waren nicht erreichbar. Benachrichtigungen können noch einige Tage ankommen. Um sie früher zu beenden, schalten Sie die erweiterten Einstellungen ein, sobald Sie wieder online sind, und widerrufen Sie dieses Gerät unter „Registrierte Geräte“." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Zu viele Versuche. Versuchen Sie es in {count, plural, one {# Minute} other {# Minuten}} noch einmal." }, "desktop_sign_in": { - "browser_button": "Mit dem Browser anmelden", - "browser_waiting": "Schließen Sie die Anmeldung im Browser ab", - "browser_unavailable": "Dieser Server bietet keine Anmeldung über den Browser an. Verwenden Sie stattdessen Ihr Passwort.", - "browser_failed": "Die Anmeldung über den Browser hat nicht geklappt. Versuchen Sie es noch einmal.", "server_needed": "Geben Sie zuerst die Adresse Ihres Servers ein.", "server_declined": "Sie haben diesen Server nicht zugelassen, daher wurde nichts an ihn gesendet.", "server_invalid": "Diese Serveradresse ist ungültig." }, + "desktop_browser_sign_in": { + "button": "Mit Ihrem Browser anmelden…", + "waiting": "Warten auf Ihren Browser…", + "waiting_hint": "Schließen Sie die Anmeldung dort ab. Ihr Passwort-Manager funktioniert in Ihrem Browser.", + "cancel": "Abbrechen", + "or_here": "oder hier anmelden", + "timed_out": "Die Anmeldung im Browser hat zu lange gedauert. Versuchen Sie es noch einmal.", + "no_browser": "Bulwark konnte Ihren Browser nicht öffnen. Versuchen Sie es noch einmal oder melden Sie sich hier an.", + "failed": "Die Anmeldung über Ihren Browser hat nicht geklappt. Versuchen Sie es noch einmal.", + "handoff_banner": "Ein Programm auf diesem Computer hat diese Anmeldung für die Bulwark-App angefordert. Dieser Browser bleibt bei Bulwark abgemeldet. Wenn Sie das nicht gestartet haben, schließen Sie diesen Tab.", + "consent_title": "Bei der Bulwark-App anmelden?", + "consent_description": "Ein Programm auf diesem Computer, an Port {port}, fragt nach der Anmeldung für {username}. Fahren Sie nur fort, wenn Sie gerade in der Bulwark-App hier auf „Mit Ihrem Browser anmelden…“ geklickt haben. Wenn nicht, versucht vielleicht jemand, in Ihr Konto zu gelangen: Wählen Sie „Jetzt nicht“.", + "consent_confirm": "Bei der App anmelden", + "consent_cancel": "Jetzt nicht", + "page_done": "Sie können diesen Tab schließen und zu Bulwark zurückkehren.", + "page_failed": "Die Anmeldung hat nicht geklappt. Gehen Sie zurück zu Bulwark und versuchen Sie es noch einmal.", + "page_link_used": "Dieser Link funktioniert nur einmal. Gehen Sie zurück zu Bulwark und beginnen Sie neu.", + "page_title": "Bei {server} anmelden", + "page_intro": "Diese Seite kommt von der Bulwark-App auf diesem Computer. Ihr Passwort geht an {server} und sonst nirgendwohin.", + "page_username": "E-Mail-Adresse oder Benutzername", + "page_password": "Passwort", + "page_code": "Zwei-Faktor-Code", + "page_submit": "Anmelden", + "page_wrong_password": "Der Server hat diesen Benutzernamen und dieses Passwort nicht akzeptiert.", + "page_code_needed": "Dieses Konto braucht außerdem einen Zwei-Faktor-Code. Geben Sie Ihr Passwort noch einmal und den Code ein.", + "page_no_answer": "Bulwark konnte sich nicht anmelden. Prüfen Sie, ob der Server erreichbar ist, und versuchen Sie es noch einmal.", + "declined": "Sie haben im Browser abgelehnt, deshalb wurde nichts angemeldet.", + "too_many": "Zu viele Anmeldeversuche aus diesem Netzwerk. Warten Sie ein paar Minuten und versuchen Sie es noch einmal.", + "page_declined": "Es wurde nichts übergeben. Sie können diesen Tab schließen." + }, "desktop_updates": { "title": "Updates", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Die Datei konnte nicht kopiert werden.", "reply_lost": "Ihre eingegebene Antwort konnte nicht übernommen werden. Öffnen Sie die Nachricht und antworten Sie dort." }, + "desktopEdit": { + "edit": "Bearbeiten", + "edit_named": "{name} bearbeiten", + "opened": "{name} ist in seinem Programm geöffnet. Speichern Sie Ihre Änderungen dort, danach werden Sie gefragt, was damit geschehen soll.", + "offers_label": "Bearbeitete Anhänge", + "changed_title": "{name} wurde geändert", + "changed_body": "Sie haben Änderungen in einem anderen Programm gespeichert. Was soll damit geschehen?", + "reply": "An eine Antwort anhängen", + "new_message": "An eine neue Nachricht anhängen", + "open_draft": "Entwurf öffnen", + "save_copy": "Kopie speichern…", + "discard": "Änderungen verwerfen…", + "discard_title": "Ihre Änderungen an {name} verwerfen?", + "discard_detail": "Die bearbeitete Kopie wird gelöscht. Der ursprüngliche Anhang bleibt, wie er ist.", + "discard_confirm": "Verwerfen", + "cancel": "Abbrechen", + "update": "Anhang aktualisieren", + "no_app": "Kein Programm auf diesem Computer kann diese Datei öffnen.", + "blocked_type": "Dieser Dateityp lässt sich nicht zum Bearbeiten öffnen, weil er als Programm laufen könnte.", + "blocked_policy": "Ihre Organisation erlaubt nicht, Anhänge auf diesem Gerät zu speichern.", + "removed_policy": "Ihre Organisation erlaubt das nicht mehr, daher wurde die bearbeitete Kopie von {name} gelöscht.", + "too_large": "Diese Datei ist zu groß, um sie zum Bearbeiten zu öffnen.", + "too_many": "Es sind zu viele Dateien zum Bearbeiten geöffnet. Schließen Sie zuerst einige davon ab.", + "failed": "Die Datei konnte nicht zum Bearbeiten geöffnet werden.", + "read_busy": "Ein anderes Programm verwendet {name} noch. Schließen Sie die Datei dort und versuchen Sie es erneut.", + "read_failed": "Die bearbeitete Kopie von {name} konnte nicht gelesen werden.", + "send_stale_title": "{name} hat Änderungen, die nicht in dieser Nachricht sind", + "send_stale_detail": "Wenn Sie jetzt senden, geht die Nachricht mit der älteren Fassung hinaus. Ihre Änderungen bleiben bis zu 30 Tage auf diesem Computer, bis Sie entscheiden, was damit geschehen soll.", + "send_stale_send": "Trotzdem senden", + "send_stale_back": "Zurück", + "signout_title": "Abmelden und Ihre Änderungen löschen?", + "signout_confirm": "Abmelden", + "lock_note": "Dateien, die Sie mit „Bearbeiten“ geöffnet haben, bleiben in ihrem Programm geöffnet, während die App gesperrt ist, und bleiben auf diesem Computer lesbar. Neue lassen sich erst nach dem Entsperren öffnen.", + "changed_title_hidden": "Ein bearbeiteter Anhang wurde gespeichert", + "last_chance": "30 Tage nicht verwendet. Diese Kopie wird morgen gelöscht.", + "held_named": "{names}: noch in einem anderen Programm geöffnet, daher kann die Kopie noch nicht gelöscht werden. Schließen Sie sie dort, dann wird sie gelöscht.", + "held_unnamed": "Die Kopie eines Anhangs ist noch in einem anderen Programm geöffnet und kann noch nicht gelöscht werden. Schließen Sie sie dort, dann wird sie gelöscht.", + "removed_policy_held": "Ihre Organisation erlaubt das nicht mehr. {name} ist noch in einem anderen Programm geöffnet, daher kann die Kopie erst gelöscht werden, wenn Sie sie dort schließen.", + "removed_policy_hidden": "Ihre Organisation erlaubt das Bearbeiten von Anhängen nicht mehr, daher wurde eine bearbeitete Kopie entfernt.", + "outside_note": "Die Wiederherstellungsdateien und die Liste zuletzt verwendeter Dateien dieses Programms kann diese App nicht löschen.", + "clear_title": "Ihre bearbeiteten Anhänge löschen?", + "clear_confirm": "Löschen", + "signout_changed": "Noch nicht angehängt oder gespeichert, und jetzt gelöscht: {names}.", + "signout_held": "Noch in einem anderen Programm geöffnet, daher erst gelöscht, wenn Sie sie dort schließen: {names}." + }, "desktopQuickCompose": { "label": "Schnell schreiben", "menu": "Schnell schreiben", diff --git a/locales/en/common.json b/locales/en/common.json index b478a1f4b..84f9389a7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Not enabled on this device", "status_unsupported": "This browser does not support Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Background notifications aren't available in the demo.", "status_busy": "Working…", "enable": "Enable", "reenable": "Re-register", @@ -1766,7 +1767,12 @@ "confirm_revoke_message_this": "This device will stop receiving background notifications immediately.", "shared_mailboxes_no_push": "Mailboxes others share with you don't send background notifications.", "error_forbidden": "The mail server doesn't allow background notifications for this account.", - "error_over_quota": "This account has too many push registrations on the mail server (the limit is usually 15). Revoke devices you no longer use in the notification settings, then try again." + "error_over_quota": "This account has too many push registrations on the mail server (the limit is usually 15). Revoke devices you no longer use in the notification settings, then try again.", + "error_blocked": "Notifications are blocked for this site. Allow them in your browser's site settings, then try again.", + "error_not_granted": "Notifications weren't allowed, so push stays off.", + "error_verification_timeout": "The mail server didn't confirm this device in time, so push stays off. Try again in a minute.", + "error_relay": "The push relay can't be reached right now, so push stays off. Try again later.", + "error_teardown": "Push is off in this browser, but the mail server or the relay couldn't be reached. Notifications may keep arriving for a few days. To stop them sooner, turn on advanced settings once you're back online and revoke this device under Registered devices." }, "push_all": { "title": "All accounts", @@ -6873,14 +6879,40 @@ "blocked_minutes": "Too many tries. Try again in {count, plural, one {# minute} other {# minutes}}." }, "desktop_sign_in": { - "browser_button": "Sign in with your browser", - "browser_waiting": "Finish signing in in your browser", - "browser_unavailable": "This server doesn't offer sign-in through a browser. Use your password instead.", - "browser_failed": "Signing in through your browser didn't work. Try again.", "server_needed": "Enter your server's address first.", "server_declined": "You didn't allow this server, so nothing was sent to it.", "server_invalid": "This server address isn't valid." }, + "desktop_browser_sign_in": { + "button": "Sign in with your browser…", + "waiting": "Waiting for your browser…", + "waiting_hint": "Finish signing in there. Your password manager works in your browser.", + "cancel": "Cancel", + "or_here": "or sign in here", + "timed_out": "The browser sign-in took too long. Try again.", + "no_browser": "Bulwark could not open your browser. Try again, or sign in here.", + "failed": "Signing in through your browser did not work. Try again.", + "handoff_banner": "A program on this computer asked for this sign-in, for the Bulwark app. This browser stays signed out of Bulwark. If you didn't start this, close this tab.", + "consent_title": "Sign in to the Bulwark app?", + "consent_description": "A program on this computer, at port {port}, is asking for the sign-in for {username}. Continue only if you just pressed \"Sign in with your browser…\" in the Bulwark app here. If you didn't, someone may be trying to get into your account: choose Not now.", + "consent_confirm": "Sign in to the app", + "consent_cancel": "Not now", + "page_done": "You can close this tab and go back to Bulwark.", + "page_failed": "Signing in did not work. Go back to Bulwark and try again.", + "page_link_used": "This link only works once. Go back to Bulwark and start again.", + "page_title": "Sign in to {server}", + "page_intro": "This page comes from the Bulwark app on this computer. Your password goes to {server} and nowhere else.", + "page_username": "Email address or username", + "page_password": "Password", + "page_code": "Two-factor code", + "page_submit": "Sign in", + "page_wrong_password": "The server did not accept this username and password.", + "page_code_needed": "This account also needs a two-factor code. Enter your password again and the code.", + "page_no_answer": "Bulwark could not sign in. Check that the server is reachable and try again.", + "declined": "You said no in your browser, so nothing was signed in.", + "too_many": "Too many sign-in attempts from this network. Wait a few minutes and try again.", + "page_declined": "Nothing was handed over. You can close this tab." + }, "desktop_updates": { "title": "Updates", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "The file could not be copied.", "reply_lost": "Your typed reply could not be kept. Open the message and reply there." }, + "desktopEdit": { + "edit": "Edit", + "edit_named": "Edit {name}", + "opened": "{name} is open in its program. Save your changes there, and you'll be asked what to do with them.", + "offers_label": "Edited attachments", + "changed_title": "{name} was changed", + "changed_body": "You saved changes in another program. What should happen to them?", + "reply": "Attach to a reply", + "new_message": "Attach to a new message", + "open_draft": "Open draft", + "save_copy": "Save a copy…", + "discard": "Discard edits…", + "discard_title": "Discard your edits to {name}?", + "discard_detail": "The edited copy is deleted. The original attachment doesn't change.", + "discard_confirm": "Discard", + "cancel": "Cancel", + "update": "Update attachment", + "no_app": "No program on this computer can open this file.", + "blocked_type": "This type of file can't be opened for editing, because it could run as a program.", + "blocked_policy": "Your organisation doesn't allow saving attachments on this device.", + "removed_policy": "Your organisation no longer allows this, so the edited copy of {name} was deleted.", + "too_large": "This file is too large to open for editing.", + "too_many": "Too many files are open for editing. Finish with some of them first.", + "failed": "The file could not be opened for editing.", + "read_busy": "Another program is still using {name}. Close it there and try again.", + "read_failed": "The edited copy of {name} could not be read.", + "send_stale_title": "{name} has edits that aren't in this message", + "send_stale_detail": "If you send now, the message goes with the older version. Your edits stay on this computer for up to 30 days, until you decide what to do with them.", + "send_stale_send": "Send anyway", + "send_stale_back": "Go back", + "signout_title": "Sign out and delete your edits?", + "signout_confirm": "Sign out", + "lock_note": "Files you opened with Edit stay open in their program while the app is locked, and they stay readable on this computer. New ones can't open until you unlock.", + "changed_title_hidden": "An attachment you edited was saved", + "last_chance": "Not used for 30 days. This copy is deleted tomorrow.", + "held_named": "{names}: still open in another program, so the copy can't be deleted yet. Close it there and it goes.", + "held_unnamed": "A copy of an attachment is still open in another program, so it can't be deleted yet. Close it there and it goes.", + "removed_policy_held": "Your organisation no longer allows this. {name} is still open in another program, so its copy can't be deleted until you close it there.", + "removed_policy_hidden": "Your organisation no longer allows editing attachments, so an edited copy was removed.", + "outside_note": "That program's own recovery files and recent-files list are outside what this app can clear.", + "clear_title": "Delete your edited attachments?", + "clear_confirm": "Delete", + "signout_changed": "Not attached or saved yet, and deleted now: {names}.", + "signout_held": "Still open in another program, so not deleted until you close it there: {names}." + }, "desktopQuickCompose": { "label": "Quick compose", "menu": "Quick compose", diff --git a/locales/es/common.json b/locales/es/common.json index adb67ce9d..a44fce868 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "No habilitado en este dispositivo", "status_unsupported": "Este navegador no admite Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Las notificaciones en segundo plano no están disponibles en la demo.", "title": "Notificaciones en segundo plano", "devices_title": "Dispositivos registrados", "devices_desc": "Todos los dispositivos registrados para recibir notificaciones en segundo plano de esta cuenta. Al revocar uno, deja de recibirlas de inmediato.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "Este dispositivo dejará de recibir notificaciones en segundo plano de inmediato.", "shared_mailboxes_no_push": "Los buzones que otras personas comparten contigo no envían notificaciones en segundo plano.", "error_forbidden": "El servidor de correo no permite notificaciones en segundo plano para esta cuenta.", - "error_over_quota": "Esta cuenta tiene demasiados registros push en el servidor de correo (el límite suele ser 15). Revoca los dispositivos que ya no uses en la configuración de notificaciones e inténtalo de nuevo." + "error_over_quota": "Esta cuenta tiene demasiados registros push en el servidor de correo (el límite suele ser 15). Revoca los dispositivos que ya no uses en la configuración de notificaciones e inténtalo de nuevo.", + "error_blocked": "Las notificaciones están bloqueadas para este sitio. Permítelas en la configuración del sitio de tu navegador y vuelve a intentarlo.", + "error_not_granted": "No se permitieron las notificaciones, así que las notificaciones push siguen desactivadas.", + "error_verification_timeout": "El servidor de correo no confirmó este dispositivo a tiempo, así que las notificaciones push siguen desactivadas. Inténtalo de nuevo en un minuto.", + "error_relay": "No se puede acceder al relé de notificaciones en este momento, así que las notificaciones push siguen desactivadas. Inténtalo más tarde.", + "error_teardown": "Las notificaciones push están desactivadas en este navegador, pero no se pudo acceder al servidor de correo o al relé. Es posible que sigan llegando notificaciones durante unos días. Para detenerlas antes, activa los ajustes avanzados cuando vuelvas a tener conexión y revoca este dispositivo en Dispositivos registrados." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Demasiados intentos. Inténtelo de nuevo en {count, plural, one {# minuto} other {# minutos}}." }, "desktop_sign_in": { - "browser_button": "Iniciar sesión con el navegador", - "browser_waiting": "Termina de iniciar sesión en el navegador", - "browser_unavailable": "Este servidor no permite iniciar sesión con el navegador. Usa tu contraseña.", - "browser_failed": "No se pudo iniciar sesión con el navegador. Inténtalo de nuevo.", "server_needed": "Primero escribe la dirección del servidor.", "server_declined": "No permitiste este servidor, así que no se le envió nada.", "server_invalid": "Esta dirección de servidor no es válida." }, + "desktop_browser_sign_in": { + "button": "Iniciar sesión con tu navegador…", + "waiting": "Esperando a tu navegador…", + "waiting_hint": "Termina de iniciar sesión allí. Tu gestor de contraseñas funciona en tu navegador.", + "cancel": "Cancelar", + "or_here": "o inicia sesión aquí", + "timed_out": "El inicio de sesión en el navegador tardó demasiado. Inténtalo de nuevo.", + "no_browser": "Bulwark no pudo abrir tu navegador. Inténtalo de nuevo o inicia sesión aquí.", + "failed": "No se pudo iniciar sesión a través de tu navegador. Inténtalo de nuevo.", + "handoff_banner": "Un programa de este ordenador ha pedido este inicio de sesión para la aplicación Bulwark. Este navegador sigue sin sesión en Bulwark. Si no lo has iniciado tú, cierra esta pestaña.", + "consent_title": "¿Iniciar sesión en la aplicación Bulwark?", + "consent_description": "Un programa de este ordenador, en el puerto {port}, pide el inicio de sesión de {username}. Continúa solo si acabas de pulsar «Iniciar sesión con tu navegador…» en la aplicación Bulwark de aquí. Si no, puede que alguien intente entrar en tu cuenta: elige Ahora no.", + "consent_confirm": "Iniciar sesión en la aplicación", + "consent_cancel": "Ahora no", + "page_done": "Puedes cerrar esta pestaña y volver a Bulwark.", + "page_failed": "No se pudo iniciar sesión. Vuelve a Bulwark e inténtalo de nuevo.", + "page_link_used": "Este enlace solo funciona una vez. Vuelve a Bulwark y empieza de nuevo.", + "page_title": "Iniciar sesión en {server}", + "page_intro": "Esta página viene de la aplicación Bulwark de este ordenador. Tu contraseña va a {server} y a ningún otro sitio.", + "page_username": "Correo electrónico o nombre de usuario", + "page_password": "Contraseña", + "page_code": "Código de dos factores", + "page_submit": "Iniciar sesión", + "page_wrong_password": "El servidor no aceptó este nombre de usuario y esta contraseña.", + "page_code_needed": "Esta cuenta también necesita un código de dos factores. Escribe de nuevo tu contraseña y el código.", + "page_no_answer": "Bulwark no pudo iniciar sesión. Comprueba que el servidor esté accesible e inténtalo de nuevo.", + "declined": "Dijiste que no en tu navegador, así que no se inició ninguna sesión.", + "too_many": "Demasiados intentos de inicio de sesión desde esta red. Espera unos minutos e inténtalo de nuevo.", + "page_declined": "No se ha entregado nada. Puedes cerrar esta pestaña." + }, "desktop_updates": { "title": "Actualizaciones", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "No se pudo copiar el archivo.", "reply_lost": "No se pudo conservar la respuesta que escribiste. Abre el mensaje y responde desde allí." }, + "desktopEdit": { + "edit": "Editar", + "edit_named": "Editar {name}", + "opened": "{name} está abierto en su programa. Guarde los cambios allí y se le preguntará qué hacer con ellos.", + "offers_label": "Adjuntos editados", + "changed_title": "{name} ha cambiado", + "changed_body": "Ha guardado cambios en otro programa. ¿Qué quiere hacer con ellos?", + "reply": "Adjuntar a una respuesta", + "new_message": "Adjuntar a un mensaje nuevo", + "open_draft": "Abrir borrador", + "save_copy": "Guardar una copia…", + "discard": "Descartar los cambios…", + "discard_title": "¿Descartar los cambios en {name}?", + "discard_detail": "Se elimina la copia editada. El adjunto original no cambia.", + "discard_confirm": "Descartar", + "cancel": "Cancelar", + "update": "Actualizar adjunto", + "no_app": "Ningún programa de este equipo puede abrir este archivo.", + "blocked_type": "Este tipo de archivo no se puede abrir para editarlo, porque podría ejecutarse como un programa.", + "blocked_policy": "Su organización no permite guardar adjuntos en este dispositivo.", + "removed_policy": "Su organización ya no lo permite, así que se eliminó la copia editada de {name}.", + "too_large": "Este archivo es demasiado grande para abrirlo y editarlo.", + "too_many": "Hay demasiados archivos abiertos para editar. Termine primero con algunos.", + "failed": "No se pudo abrir el archivo para editarlo.", + "read_busy": "Otro programa sigue usando {name}. Ciérrelo allí e inténtelo de nuevo.", + "read_failed": "No se pudo leer la copia editada de {name}.", + "send_stale_title": "{name} tiene cambios que no están en este mensaje", + "send_stale_detail": "Si envía ahora, el mensaje sale con la versión anterior. Sus cambios se quedan en este equipo hasta 30 días, hasta que decida qué hacer con ellos.", + "send_stale_send": "Enviar de todos modos", + "send_stale_back": "Volver", + "signout_title": "¿Cerrar sesión y eliminar sus cambios?", + "signout_confirm": "Cerrar sesión", + "lock_note": "Los archivos abiertos con Editar siguen abiertos en su programa mientras la aplicación está bloqueada, y siguen siendo legibles en este equipo. No se pueden abrir otros hasta que la desbloquee.", + "changed_title_hidden": "Se guardó un adjunto editado", + "last_chance": "Sin usar durante 30 días. Esta copia se elimina mañana.", + "held_named": "{names}: sigue abierto en otro programa, así que la copia aún no se puede eliminar. Ciérrelo allí y se eliminará.", + "held_unnamed": "La copia de un adjunto sigue abierta en otro programa y aún no se puede eliminar. Ciérrela allí y se eliminará.", + "removed_policy_held": "Su organización ya no lo permite. {name} sigue abierto en otro programa, así que su copia no se puede eliminar hasta que lo cierre allí.", + "removed_policy_hidden": "Su organización ya no permite editar adjuntos, así que se quitó una copia editada.", + "outside_note": "Los archivos de recuperación y la lista de archivos recientes de ese programa quedan fuera del alcance de esta aplicación.", + "clear_title": "¿Eliminar sus adjuntos editados?", + "clear_confirm": "Eliminar", + "signout_changed": "Aún sin adjuntar ni guardar, y eliminados ahora: {names}.", + "signout_held": "Siguen abiertos en otro programa, así que no se eliminan hasta que los cierre allí: {names}." + }, "desktopQuickCompose": { "label": "Redacción rápida", "menu": "Redacción rápida", diff --git a/locales/et/common.json b/locales/et/common.json index 4eb83347b..ace51f3b5 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Selles seadmes pole lubatud", "status_unsupported": "See brauser ei toeta Web Pushi", "status_lite": "Veebi-tõukemärguanded pole Bulwark Lite'is saadaval; need vajavad Bulwarki serverit.", + "status_demo": "Taustateavitused pole demos saadaval.", "status_busy": "Töötleb…", "enable": "Luba", "reenable": "Registreeri uuesti", @@ -1766,7 +1767,12 @@ "confirm_revoke_message_this": "See seade lakkab taustateavitusi koheselt saamast.", "shared_mailboxes_no_push": "Postkastid, mida teised sinuga jagavad, taustateavitusi ei saada.", "error_forbidden": "Meiliserver ei luba selle konto jaoks taustateavitusi.", - "error_over_quota": "Sellel kontol on meiliserveris liiga palju pushi registreeringuid (piirang on tavaliselt 15). Tühista teavituste seadetes seadmed, mida sa enam ei kasuta, ja proovi uuesti." + "error_over_quota": "Sellel kontol on meiliserveris liiga palju pushi registreeringuid (piirang on tavaliselt 15). Tühista teavituste seadetes seadmed, mida sa enam ei kasuta, ja proovi uuesti.", + "error_blocked": "Teavitused on selle saidi jaoks blokeeritud. Luba need brauseri saidiseadetes ja proovi uuesti.", + "error_not_granted": "Teavitusi ei lubatud, seega jääb tõuketeavitus välja.", + "error_verification_timeout": "E-posti server ei kinnitanud seda seadet õigel ajal, seega jääb tõuketeavitus välja. Proovi minuti pärast uuesti.", + "error_relay": "Tõuketeavituste releed ei saa praegu kätte, seega jääb tõuketeavitus välja. Proovi hiljem uuesti.", + "error_teardown": "Tõuketeavitus on selles brauseris väljas, kuid e-posti serverit või releed ei saadud kätte. Teavitusi võib veel mõne päeva tulla. Et need varem lõpetada, lülita võrku naastes sisse täpsemad seaded ja tühista see seade jaotises Registreeritud seadmed." }, "push_all": { "title": "Kõik kontod", @@ -6873,14 +6879,40 @@ "blocked_minutes": "Liiga palju katseid. Proovige uuesti {count, plural, one {# minuti} other {# minuti}} pärast." }, "desktop_sign_in": { - "browser_button": "Logi sisse brauseriga", - "browser_waiting": "Lõpeta sisselogimine brauseris", - "browser_unavailable": "See server ei paku brauseri kaudu sisselogimist. Kasuta parooli.", - "browser_failed": "Brauseri kaudu sisselogimine ei õnnestunud. Proovi uuesti.", "server_needed": "Sisesta kõigepealt serveri aadress.", "server_declined": "Sa ei lubanud seda serverit, nii et sellele ei saadetud midagi.", "server_invalid": "See serveri aadress ei kehti." }, + "desktop_browser_sign_in": { + "button": "Logi sisse brauseriga…", + "waiting": "Ootan brauserit…", + "waiting_hint": "Lõpeta sisselogimine seal. Sinu paroolihaldur töötab brauseris.", + "cancel": "Tühista", + "or_here": "või logi sisse siin", + "timed_out": "Sisselogimine brauseris võttis liiga kaua aega. Proovi uuesti.", + "no_browser": "Bulwark ei saanud brauserit avada. Proovi uuesti või logi sisse siin.", + "failed": "Sisselogimine brauseri kaudu ei õnnestunud. Proovi uuesti.", + "handoff_banner": "Selle arvuti programm küsis seda sisselogimist Bulwarki rakendusele. See brauser jääb Bulwarkist välja logituks. Kui sina seda ei alustanud, sulge see vaheleht.", + "consent_title": "Kas logida sisse Bulwarki rakendusse?", + "consent_description": "Selle arvuti programm pordil {port} küsib konto {username} sisselogimist. Jätka ainult siis, kui vajutasid just selle arvuti Bulwarki rakenduses nuppu „Logi sisse brauseriga…“. Kui mitte, võib keegi proovida sinu kontole pääseda: vali Mitte praegu.", + "consent_confirm": "Logi rakendusse sisse", + "consent_cancel": "Mitte praegu", + "page_done": "Võid selle vahelehe sulgeda ja Bulwarki naasta.", + "page_failed": "Sisselogimine ei õnnestunud. Mine tagasi Bulwarki ja proovi uuesti.", + "page_link_used": "See link töötab ainult üks kord. Mine tagasi Bulwarki ja alusta uuesti.", + "page_title": "Logi sisse serverisse {server}", + "page_intro": "See leht tuleb selle arvuti Bulwarki rakendusest. Sinu parool läheb serverisse {server} ja mitte kuhugi mujale.", + "page_username": "E-posti aadress või kasutajanimi", + "page_password": "Parool", + "page_code": "Kaheastmelise kinnituse kood", + "page_submit": "Logi sisse", + "page_wrong_password": "Server ei aktsepteerinud seda kasutajanime ja parooli.", + "page_code_needed": "See konto vajab ka kaheastmelise kinnituse koodi. Sisesta parool uuesti ja kood.", + "page_no_answer": "Bulwark ei saanud sisse logida. Kontrolli, kas server on kättesaadav, ja proovi uuesti.", + "declined": "Ütlesid brauseris ei, seega sisse ei logitud.", + "too_many": "Sellest võrgust on liiga palju sisselogimiskatseid. Oota paar minutit ja proovi uuesti.", + "page_declined": "Midagi ei antud üle. Võid selle vahelehe sulgeda." + }, "desktop_updates": { "title": "Uuendused", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Faili ei saanud kopeerida.", "reply_lost": "Kirjutatud vastust ei saanud säilitada. Ava kiri ja vasta seal." }, + "desktopEdit": { + "edit": "Muuda", + "edit_named": "Muuda faili {name}", + "opened": "{name} on oma programmis avatud. Salvesta muudatused seal ja sinult küsitakse, mida nendega teha.", + "offers_label": "Muudetud manused", + "changed_title": "{name} on muutunud", + "changed_body": "Salvestasid muudatused teises programmis. Mida nendega teha?", + "reply": "Lisa vastusele", + "new_message": "Lisa uuele kirjale", + "open_draft": "Ava mustand", + "save_copy": "Salvesta koopia…", + "discard": "Loobu muudatustest…", + "discard_title": "Kas loobuda faili {name} muudatustest?", + "discard_detail": "Muudetud koopia kustutatakse. Algne manus ei muutu.", + "discard_confirm": "Loobu", + "cancel": "Tühista", + "update": "Uuenda manust", + "no_app": "Ükski selle arvuti programm ei saa seda faili avada.", + "blocked_type": "Seda tüüpi faili ei saa muutmiseks avada, sest see võib käivituda programmina.", + "blocked_policy": "Sinu organisatsioon ei luba selles seadmes manuseid salvestada.", + "removed_policy": "Sinu organisatsioon ei luba seda enam, seega kustutati faili {name} muudetud koopia.", + "too_large": "See fail on muutmiseks avamiseks liiga suur.", + "too_many": "Muutmiseks on avatud liiga palju faile. Lõpeta kõigepealt mõned neist.", + "failed": "Faili ei saanud muutmiseks avada.", + "read_busy": "Teine programm kasutab faili {name} endiselt. Sulge see seal ja proovi uuesti.", + "read_failed": "Faili {name} muudetud koopiat ei saanud lugeda.", + "send_stale_title": "Failis {name} on muudatusi, mida selles kirjas pole", + "send_stale_detail": "Kui saadad praegu, läheb kiri vanema versiooniga. Sinu muudatused jäävad sellesse arvutisse kuni 30 päevaks, kuni otsustad, mida nendega teha.", + "send_stale_send": "Saada ikkagi", + "send_stale_back": "Tagasi", + "signout_title": "Kas logida välja ja kustutada muudatused?", + "signout_confirm": "Logi välja", + "lock_note": "Muutmiseks avatud failid jäävad oma programmis avatuks ka siis, kui rakendus on lukus, ja on selles arvutis endiselt loetavad. Uusi ei saa enne lukust avamist avada.", + "changed_title_hidden": "Muudetud manus salvestati", + "last_chance": "30 päeva kasutamata. See koopia kustutatakse homme.", + "held_named": "{names}: endiselt teises programmis avatud, seega ei saa koopiat veel kustutada. Sulge see seal ja see kustutatakse.", + "held_unnamed": "Manuse koopia on endiselt teises programmis avatud ja seda ei saa veel kustutada. Sulge see seal ja see kustutatakse.", + "removed_policy_held": "Sinu organisatsioon ei luba seda enam. {name} on endiselt teises programmis avatud, seega saab koopia kustutada alles siis, kui selle seal sulged.", + "removed_policy_hidden": "Sinu organisatsioon ei luba enam manuseid muuta, seega eemaldati muudetud koopia.", + "outside_note": "Selle programmi taastefaile ja viimaste failide loendit see rakendus kustutada ei saa.", + "clear_title": "Kas kustutada muudetud manused?", + "clear_confirm": "Kustuta", + "signout_changed": "Veel lisamata ja salvestamata ning nüüd kustutatud: {names}.", + "signout_held": "Endiselt teises programmis avatud, seega kustutatakse alles pärast sulgemist: {names}." + }, "desktopQuickCompose": { "label": "Kiirkiri", "menu": "Kiirkiri", diff --git a/locales/fa/common.json b/locales/fa/common.json index 2ee1df545..9961aab01 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "فعال نشده", "status_unsupported": "مرورگر پشتیبانی نمی‌کند", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "اعلان‌های پس‌زمینه در نسخهٔ نمایشی در دسترس نیستند.", "status_busy": "در حال کار…", "enable": "فعال کردن", "reenable": "ثبت مجدد", @@ -1766,7 +1767,12 @@ "confirm_revoke_message_this": "این دستگاه بی‌درنگ دریافت اعلان‌های پس‌زمینه را متوقف می‌کند.", "shared_mailboxes_no_push": "صندوق‌های پستی که دیگران با شما به اشتراک می‌گذارند اعلان پس‌زمینه ارسال نمی‌کنند.", "error_forbidden": "سرور ایمیل اعلان‌های پس‌زمینه را برای این حساب مجاز نمی‌داند.", - "error_over_quota": "این حساب روی سرور ایمیل ثبت‌های پوش بیش از حد دارد (سقف معمولاً 15 است). دستگاه‌هایی را که دیگر استفاده نمی‌کنید در تنظیمات اعلان‌ها لغو کنید و دوباره امتحان کنید." + "error_over_quota": "این حساب روی سرور ایمیل ثبت‌های پوش بیش از حد دارد (سقف معمولاً 15 است). دستگاه‌هایی را که دیگر استفاده نمی‌کنید در تنظیمات اعلان‌ها لغو کنید و دوباره امتحان کنید.", + "error_blocked": "اعلان‌ها برای این سایت مسدود هستند. آن‌ها را در تنظیمات سایت مرورگر مجاز کنید و دوباره امتحان کنید.", + "error_not_granted": "اعلان‌ها مجاز نشدند، پس اعلان فوری خاموش می‌ماند.", + "error_verification_timeout": "سرور ایمیل این دستگاه را به‌موقع تأیید نکرد، پس اعلان فوری خاموش می‌ماند. یک دقیقه دیگر دوباره امتحان کنید.", + "error_relay": "در حال حاضر دسترسی به رله اعلان ممکن نیست، پس اعلان فوری خاموش می‌ماند. بعداً دوباره امتحان کنید.", + "error_teardown": "اعلان فوری در این مرورگر خاموش شد، اما دسترسی به سرور ایمیل یا رله ممکن نبود. ممکن است تا چند روز اعلان‌ها برسند. برای قطع زودتر آن‌ها، وقتی دوباره آنلاین شدید تنظیمات پیشرفته را روشن کنید و این دستگاه را در بخش دستگاه‌های ثبت‌شده لغو کنید." }, "push_all": { "title": "All accounts", @@ -6873,14 +6879,40 @@ "blocked_minutes": "تلاش‌های زیاد. {count, plural, one {# دقیقه} other {# دقیقه}} دیگر دوباره تلاش کنید." }, "desktop_sign_in": { - "browser_button": "ورود با مرورگر", - "browser_waiting": "ورود را در مرورگر کامل کنید", - "browser_unavailable": "این سرور ورود از طریق مرورگر را ارائه نمی‌دهد. از گذرواژه خود استفاده کنید.", - "browser_failed": "ورود از طریق مرورگر انجام نشد. دوباره تلاش کنید.", "server_needed": "ابتدا نشانی سرور را وارد کنید.", "server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.", "server_invalid": "این نشانی سرور معتبر نیست." }, + "desktop_browser_sign_in": { + "button": "ورود با مرورگر…", + "waiting": "در انتظار مرورگر…", + "waiting_hint": "ورود را همان‌جا کامل کنید. مدیر گذرواژه شما در مرورگر کار می‌کند.", + "cancel": "لغو", + "or_here": "یا اینجا وارد شوید", + "timed_out": "ورود در مرورگر بیش از حد طول کشید. دوباره تلاش کنید.", + "no_browser": "Bulwark نتوانست مرورگر را باز کند. دوباره تلاش کنید یا اینجا وارد شوید.", + "failed": "ورود از طریق مرورگر انجام نشد. دوباره تلاش کنید.", + "handoff_banner": "برنامه‌ای روی این رایانه این ورود را برای برنامه Bulwark درخواست کرده است. این مرورگر همچنان از Bulwark خارج می‌ماند. اگر شما این کار را شروع نکرده‌اید، این زبانه را ببندید.", + "consent_title": "به برنامه Bulwark وارد می‌شوید؟", + "consent_description": "برنامه‌ای روی این رایانه، در درگاه {port}، ورود {username} را درخواست می‌کند. فقط اگر همین الان «ورود با مرورگر…» را در برنامه Bulwark اینجا زده‌اید ادامه دهید. در غیر این صورت شاید کسی بخواهد وارد حساب شما شود: «اکنون نه» را انتخاب کنید.", + "consent_confirm": "ورود به برنامه", + "consent_cancel": "اکنون نه", + "page_done": "می‌توانید این زبانه را ببندید و به Bulwark برگردید.", + "page_failed": "ورود انجام نشد. به Bulwark برگردید و دوباره تلاش کنید.", + "page_link_used": "این پیوند فقط یک بار کار می‌کند. به Bulwark برگردید و از نو شروع کنید.", + "page_title": "ورود به {server}", + "page_intro": "این صفحه از برنامه Bulwark روی این رایانه می‌آید. گذرواژه شما فقط به {server} می‌رود.", + "page_username": "نشانی ایمیل یا نام کاربری", + "page_password": "گذرواژه", + "page_code": "کد دومرحله‌ای", + "page_submit": "ورود", + "page_wrong_password": "سرور این نام کاربری و گذرواژه را نپذیرفت.", + "page_code_needed": "این حساب به کد دومرحله‌ای هم نیاز دارد. گذرواژه و کد را دوباره وارد کنید.", + "page_no_answer": "Bulwark نتوانست وارد شود. بررسی کنید که سرور در دسترس باشد و دوباره تلاش کنید.", + "declined": "در مرورگر رد کردید، پس ورودی انجام نشد.", + "too_many": "تلاش‌های ورود از این شبکه بیش از حد است. چند دقیقه صبر کنید و دوباره تلاش کنید.", + "page_declined": "چیزی تحویل داده نشد. می‌توانید این زبانه را ببندید." + }, "desktop_updates": { "title": "به‌روزرسانی‌ها", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "رونوشت پرونده ممکن نشد.", "reply_lost": "پاسخی که نوشتید نگه داشته نشد. پیام را باز کنید و از آنجا پاسخ دهید." }, + "desktopEdit": { + "edit": "ویرایش", + "edit_named": "ویرایش {name}", + "opened": "{name} در برنامه خودش باز است. تغییرات را همان‌جا ذخیره کنید تا از شما پرسیده شود با آنها چه شود.", + "offers_label": "پیوست‌های ویرایش‌شده", + "changed_title": "{name} تغییر کرد", + "changed_body": "تغییراتی را در برنامه دیگری ذخیره کردید. با آنها چه شود؟", + "reply": "پیوست به پاسخ", + "new_message": "پیوست به پیام جدید", + "open_draft": "باز کردن پیش‌نویس", + "save_copy": "ذخیره یک نسخه…", + "discard": "دور انداختن ویرایش‌ها…", + "discard_title": "ویرایش‌های {name} دور انداخته شود؟", + "discard_detail": "نسخه ویرایش‌شده حذف می‌شود. پیوست اصلی تغییر نمی‌کند.", + "discard_confirm": "دور انداختن", + "cancel": "انصراف", + "update": "به‌روزرسانی پیوست", + "no_app": "هیچ برنامه‌ای در این رایانه نمی‌تواند این فایل را باز کند.", + "blocked_type": "این نوع فایل را نمی‌توان برای ویرایش باز کرد، چون ممکن است مانند برنامه اجرا شود.", + "blocked_policy": "سازمان شما اجازه ذخیره پیوست‌ها در این دستگاه را نمی‌دهد.", + "removed_policy": "سازمان شما دیگر اجازه این کار را نمی‌دهد، بنابراین نسخه ویرایش‌شده {name} حذف شد.", + "too_large": "این فایل برای باز شدن جهت ویرایش خیلی بزرگ است.", + "too_many": "فایل‌های زیادی برای ویرایش باز است. ابتدا کار برخی از آنها را تمام کنید.", + "failed": "فایل برای ویرایش باز نشد.", + "read_busy": "برنامه دیگری هنوز از {name} استفاده می‌کند. آن را همان‌جا ببندید و دوباره امتحان کنید.", + "read_failed": "نسخه ویرایش‌شده {name} خوانده نشد.", + "send_stale_title": "{name} ویرایش‌هایی دارد که در این پیام نیست", + "send_stale_detail": "اگر اکنون بفرستید، پیام با نسخه قدیمی‌تر می‌رود. ویرایش‌های شما تا ۳۰ روز در این رایانه می‌ماند تا تصمیم بگیرید.", + "send_stale_send": "با این حال بفرست", + "send_stale_back": "بازگشت", + "signout_title": "خروج از حساب و حذف ویرایش‌ها؟", + "signout_confirm": "خروج از حساب", + "lock_note": "فایل‌هایی که با «ویرایش» باز کرده‌اید تا وقتی برنامه قفل است در برنامه خود باز می‌مانند و در این رایانه قابل خواندن هستند. فایل‌های جدید تا باز کردن قفل باز نمی‌شوند.", + "changed_title_hidden": "یک پیوست ویرایش‌شده ذخیره شد", + "last_chance": "۳۰ روز استفاده نشده است. این نسخه فردا حذف می‌شود.", + "held_named": "{names}: هنوز در برنامه دیگری باز است، پس نسخه فعلاً حذف نمی‌شود. آن را همان‌جا ببندید تا حذف شود.", + "held_unnamed": "نسخه یک پیوست هنوز در برنامه دیگری باز است و فعلاً حذف نمی‌شود. آن را همان‌جا ببندید تا حذف شود.", + "removed_policy_held": "سازمان شما دیگر اجازه این کار را نمی‌دهد. {name} هنوز در برنامه دیگری باز است، پس نسخه آن فقط پس از بستن در همان‌جا حذف می‌شود.", + "removed_policy_hidden": "سازمان شما دیگر اجازه ویرایش پیوست‌ها را نمی‌دهد، پس یک نسخه ویرایش‌شده برداشته شد.", + "outside_note": "این برنامه نمی‌تواند فایل‌های بازیابی و فهرست فایل‌های اخیر آن برنامه را پاک کند.", + "clear_title": "پیوست‌های ویرایش‌شده حذف شوند؟", + "clear_confirm": "حذف", + "signout_changed": "هنوز پیوست یا ذخیره نشده و اکنون حذف می‌شود: {names}.", + "signout_held": "هنوز در برنامه دیگری باز است، پس فقط پس از بستن در همان‌جا حذف می‌شود: {names}." + }, "desktopQuickCompose": { "label": "نوشتن سریع", "menu": "نوشتن سریع", diff --git a/locales/fr/common.json b/locales/fr/common.json index d80d3a08f..750e84417 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Non activé sur cet appareil", "status_unsupported": "Ce navigateur ne prend pas en charge Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Les notifications en arrière-plan ne sont pas disponibles dans la démo.", "title": "Notifications en arrière-plan", "devices_title": "Appareils enregistrés", "devices_desc": "Tous les appareils enregistrés pour recevoir les notifications en arrière-plan de ce compte. Révoquer un appareil l’empêche immédiatement d’en recevoir.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "Cet appareil cessera immédiatement de recevoir les notifications en arrière-plan.", "shared_mailboxes_no_push": "Les boîtes aux lettres que d'autres personnes partagent avec vous n'envoient pas de notifications en arrière-plan.", "error_forbidden": "Le serveur de messagerie n'autorise pas les notifications en arrière-plan pour ce compte.", - "error_over_quota": "Ce compte a trop d'enregistrements push sur le serveur de messagerie (la limite est généralement de 15). Révoquez les appareils que vous n'utilisez plus dans les paramètres de notification, puis réessayez." + "error_over_quota": "Ce compte a trop d'enregistrements push sur le serveur de messagerie (la limite est généralement de 15). Révoquez les appareils que vous n'utilisez plus dans les paramètres de notification, puis réessayez.", + "error_blocked": "Les notifications sont bloquées pour ce site. Autorisez-les dans les paramètres du site de votre navigateur, puis réessayez.", + "error_not_granted": "Les notifications n'ont pas été autorisées, les notifications push restent donc désactivées.", + "error_verification_timeout": "Le serveur de messagerie n'a pas confirmé cet appareil à temps, les notifications push restent donc désactivées. Réessayez dans une minute.", + "error_relay": "Le relais de notifications est injoignable pour le moment, les notifications push restent donc désactivées. Réessayez plus tard.", + "error_teardown": "Les notifications push sont désactivées dans ce navigateur, mais le serveur de messagerie ou le relais n'a pas pu être joint. Des notifications peuvent encore arriver pendant quelques jours. Pour les arrêter plus tôt, activez les paramètres avancés une fois de nouveau en ligne et révoquez cet appareil sous Appareils enregistrés." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Trop d'essais. Réessayez dans {count, plural, one {# minute} other {# minutes}}." }, "desktop_sign_in": { - "browser_button": "Se connecter avec le navigateur", - "browser_waiting": "Terminez la connexion dans votre navigateur", - "browser_unavailable": "Ce serveur ne propose pas la connexion par navigateur. Utilisez votre mot de passe.", - "browser_failed": "La connexion par navigateur a échoué. Réessayez.", "server_needed": "Saisissez d'abord l'adresse du serveur.", "server_declined": "Vous n'avez pas autorisé ce serveur, donc rien ne lui a été envoyé.", "server_invalid": "Cette adresse de serveur n'est pas valide." }, + "desktop_browser_sign_in": { + "button": "Se connecter avec votre navigateur…", + "waiting": "En attente de votre navigateur…", + "waiting_hint": "Terminez la connexion là-bas. Votre gestionnaire de mots de passe fonctionne dans votre navigateur.", + "cancel": "Annuler", + "or_here": "ou se connecter ici", + "timed_out": "La connexion dans le navigateur a pris trop de temps. Réessayez.", + "no_browser": "Bulwark n’a pas pu ouvrir votre navigateur. Réessayez ou connectez-vous ici.", + "failed": "La connexion via votre navigateur n’a pas fonctionné. Réessayez.", + "handoff_banner": "Un programme sur cet ordinateur a demandé cette connexion pour l’application Bulwark. Ce navigateur reste déconnecté de Bulwark. Si vous n’êtes pas à l’origine de cela, fermez cet onglet.", + "consent_title": "Se connecter à l’application Bulwark ?", + "consent_description": "Un programme sur cet ordinateur, au port {port}, demande la connexion de {username}. Continuez seulement si vous venez de cliquer sur « Se connecter avec votre navigateur… » dans l’application Bulwark ici. Sinon, quelqu’un essaie peut-être d’accéder à votre compte : choisissez Pas maintenant.", + "consent_confirm": "Se connecter à l’application", + "consent_cancel": "Pas maintenant", + "page_done": "Vous pouvez fermer cet onglet et revenir à Bulwark.", + "page_failed": "La connexion n’a pas fonctionné. Revenez à Bulwark et réessayez.", + "page_link_used": "Ce lien ne fonctionne qu’une fois. Revenez à Bulwark et recommencez.", + "page_title": "Se connecter à {server}", + "page_intro": "Cette page vient de l’application Bulwark sur cet ordinateur. Votre mot de passe va à {server} et nulle part ailleurs.", + "page_username": "Adresse e-mail ou nom d’utilisateur", + "page_password": "Mot de passe", + "page_code": "Code à deux facteurs", + "page_submit": "Se connecter", + "page_wrong_password": "Le serveur n’a pas accepté ce nom d’utilisateur et ce mot de passe.", + "page_code_needed": "Ce compte demande aussi un code à deux facteurs. Saisissez à nouveau votre mot de passe et le code.", + "page_no_answer": "Bulwark n’a pas pu se connecter. Vérifiez que le serveur est joignable et réessayez.", + "declined": "Vous avez refusé dans votre navigateur : aucune connexion n’a été faite.", + "too_many": "Trop de tentatives de connexion depuis ce réseau. Attendez quelques minutes et réessayez.", + "page_declined": "Rien n’a été transmis. Vous pouvez fermer cet onglet." + }, "desktop_updates": { "title": "Mises à jour", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Le fichier n'a pas pu être copié.", "reply_lost": "Votre réponse saisie n'a pas pu être conservée. Ouvrez le message et répondez depuis celui-ci." }, + "desktopEdit": { + "edit": "Modifier", + "edit_named": "Modifier {name}", + "opened": "{name} est ouvert dans son programme. Enregistrez vos modifications dans ce programme : on vous demandera ensuite quoi en faire.", + "offers_label": "Pièces jointes modifiées", + "changed_title": "{name} a été modifié", + "changed_body": "Vous avez enregistré des modifications dans un autre programme. Que voulez-vous en faire ?", + "reply": "Joindre à une réponse", + "new_message": "Joindre à un nouveau message", + "open_draft": "Ouvrir le brouillon", + "save_copy": "Enregistrer une copie…", + "discard": "Abandonner les modifications…", + "discard_title": "Abandonner vos modifications de {name} ?", + "discard_detail": "La copie modifiée est supprimée. La pièce jointe d’origine ne change pas.", + "discard_confirm": "Abandonner", + "cancel": "Annuler", + "update": "Mettre à jour la pièce jointe", + "no_app": "Aucun programme de cet ordinateur ne peut ouvrir ce fichier.", + "blocked_type": "Ce type de fichier ne peut pas être ouvert pour modification, car il pourrait s’exécuter comme un programme.", + "blocked_policy": "Votre organisation n’autorise pas l’enregistrement des pièces jointes sur cet appareil.", + "removed_policy": "Votre organisation ne l’autorise plus : la copie modifiée de {name} a été supprimée.", + "too_large": "Ce fichier est trop volumineux pour être ouvert et modifié.", + "too_many": "Trop de fichiers sont ouverts pour modification. Terminez-en quelques-uns d’abord.", + "failed": "Le fichier n’a pas pu être ouvert pour modification.", + "read_busy": "Un autre programme utilise encore {name}. Fermez-le dans ce programme et réessayez.", + "read_failed": "La copie modifiée de {name} n’a pas pu être lue.", + "send_stale_title": "{name} contient des modifications absentes de ce message", + "send_stale_detail": "Si vous envoyez maintenant, le message part avec l’ancienne version. Vos modifications restent sur cet ordinateur jusqu’à 30 jours, le temps de décider quoi en faire.", + "send_stale_send": "Envoyer quand même", + "send_stale_back": "Revenir", + "signout_title": "Vous déconnecter et supprimer vos modifications ?", + "signout_confirm": "Se déconnecter", + "lock_note": "Les fichiers ouverts avec Modifier restent ouverts dans leur programme pendant que l’application est verrouillée, et restent lisibles sur cet ordinateur. Aucun nouveau fichier ne peut s’ouvrir avant le déverrouillage.", + "changed_title_hidden": "Une pièce jointe modifiée a été enregistrée", + "last_chance": "Inutilisée depuis 30 jours. Cette copie sera supprimée demain.", + "held_named": "{names} : encore ouvert dans un autre programme, la copie ne peut donc pas encore être supprimée. Fermez-le dans ce programme et elle le sera.", + "held_unnamed": "La copie d’une pièce jointe est encore ouverte dans un autre programme et ne peut pas encore être supprimée. Fermez-la dans ce programme et elle le sera.", + "removed_policy_held": "Votre organisation ne l’autorise plus. {name} est encore ouvert dans un autre programme : sa copie ne peut être supprimée qu’une fois fermée dans ce programme.", + "removed_policy_hidden": "Votre organisation n’autorise plus la modification des pièces jointes : une copie modifiée a été retirée.", + "outside_note": "Les fichiers de récupération et la liste des fichiers récents de ce programme échappent à cette application.", + "clear_title": "Supprimer vos pièces jointes modifiées ?", + "clear_confirm": "Supprimer", + "signout_changed": "Pas encore jointes ni enregistrées, et supprimées maintenant : {names}.", + "signout_held": "Encore ouvertes dans un autre programme, donc supprimées seulement une fois fermées : {names}." + }, "desktopQuickCompose": { "label": "Rédaction rapide", "menu": "Rédaction rapide", diff --git a/locales/he/common.json b/locales/he/common.json index e3dc77704..23cb11fa8 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1716,6 +1716,7 @@ "status_inactive": "לא מופעל בהתקן זה", "status_unsupported": "דפדפן זה אינו תומך בדחיפה ברשת", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "התראות ברקע אינן זמינות בהדגמה.", "status_busy": "עובד…", "enable": "הפעל", "reenable": "רשום מחדש", @@ -1742,7 +1743,12 @@ "confirm_revoke_message_this": "מכשיר זה יפסיק לקבל התראות ברקע מיד.", "shared_mailboxes_no_push": "תיבות דואר שאחרים משתפים איתך אינן שולחות התראות ברקע.", "error_forbidden": "שרת הדואר אינו מאפשר התראות ברקע עבור חשבון זה.", - "error_over_quota": "לחשבון זה יש יותר מדי רישומי פוש בשרת הדואר (המגבלה היא בדרך כלל 15). בטל בהגדרות ההתראות מכשירים שאינך משתמש בהם עוד, ונסה שוב." + "error_over_quota": "לחשבון זה יש יותר מדי רישומי פוש בשרת הדואר (המגבלה היא בדרך כלל 15). בטל בהגדרות ההתראות מכשירים שאינך משתמש בהם עוד, ונסה שוב.", + "error_blocked": "ההתראות חסומות עבור אתר זה. אפשר אותן בהגדרות האתר בדפדפן ונסה שוב.", + "error_not_granted": "ההתראות לא אושרו, ולכן התראות הפוש נשארות כבויות.", + "error_verification_timeout": "שרת הדואר לא אישר את המכשיר הזה בזמן, ולכן התראות הפוש נשארות כבויות. נסה שוב בעוד דקה.", + "error_relay": "אי אפשר להגיע כרגע לממסר ההתראות, ולכן התראות הפוש נשארות כבויות. נסה שוב מאוחר יותר.", + "error_teardown": "התראות הפוש כבויות בדפדפן הזה, אבל לא ניתן היה להגיע לשרת הדואר או לממסר. ייתכן שהתראות ימשיכו להגיע עוד כמה ימים. כדי להפסיק אותן מוקדם יותר, כשתחזור להיות מחובר הפעל את ההגדרות המתקדמות ובטל את המכשיר הזה תחת מכשירים רשומים." } }, "language_region": { @@ -6874,14 +6880,40 @@ "blocked_minutes": "יותר מדי ניסיונות. נסו שוב בעוד {count, plural, one {דקה אחת} two {שתי דקות} other {# דקות}}." }, "desktop_sign_in": { - "browser_button": "כניסה באמצעות הדפדפן", - "browser_waiting": "יש להשלים את הכניסה בדפדפן", - "browser_unavailable": "השרת הזה לא מאפשר כניסה דרך דפדפן. יש להשתמש בסיסמה.", - "browser_failed": "הכניסה דרך הדפדפן לא הצליחה. כדאי לנסות שוב.", "server_needed": "קודם צריך להזין את כתובת השרת.", "server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.", "server_invalid": "כתובת השרת הזו לא תקינה." }, + "desktop_browser_sign_in": { + "button": "התחברות דרך הדפדפן…", + "waiting": "ממתין לדפדפן…", + "waiting_hint": "סיימו את ההתחברות שם. מנהל הסיסמאות שלכם פועל בדפדפן.", + "cancel": "ביטול", + "or_here": "או התחברו כאן", + "timed_out": "ההתחברות בדפדפן נמשכה זמן רב מדי. נסו שוב.", + "no_browser": "Bulwark לא הצליח לפתוח את הדפדפן. נסו שוב או התחברו כאן.", + "failed": "ההתחברות דרך הדפדפן לא הצליחה. נסו שוב.", + "handoff_banner": "תוכנה במחשב זה ביקשה את ההתחברות הזו עבור אפליקציית Bulwark. הדפדפן הזה נשאר מנותק מ-Bulwark. אם לא אתם התחלתם את זה, סגרו את הכרטיסייה.", + "consent_title": "להתחבר לאפליקציית Bulwark?", + "consent_description": "תוכנה במחשב זה, בפורט {port}, מבקשת את ההתחברות של {username}. המשיכו רק אם לחצתם עכשיו על \"התחברות דרך הדפדפן…\" באפליקציית Bulwark כאן. אם לא, ייתכן שמישהו מנסה להיכנס לחשבון שלכם: בחרו \"לא עכשיו\".", + "consent_confirm": "התחברות לאפליקציה", + "consent_cancel": "לא עכשיו", + "page_done": "אפשר לסגור את הכרטיסייה הזו ולחזור ל-Bulwark.", + "page_failed": "ההתחברות לא הצליחה. חזרו ל-Bulwark ונסו שוב.", + "page_link_used": "הקישור הזה עובד פעם אחת בלבד. חזרו ל-Bulwark והתחילו מחדש.", + "page_title": "התחברות אל {server}", + "page_intro": "הדף הזה מגיע מאפליקציית Bulwark במחשב זה. הסיסמה שלכם נשלחת אל {server} בלבד.", + "page_username": "כתובת דוא״ל או שם משתמש", + "page_password": "סיסמה", + "page_code": "קוד אימות דו-שלבי", + "page_submit": "התחברות", + "page_wrong_password": "השרת לא קיבל את שם המשתמש והסיסמה האלה.", + "page_code_needed": "החשבון הזה צריך גם קוד אימות דו-שלבי. הזינו שוב את הסיסמה ואת הקוד.", + "page_no_answer": "Bulwark לא הצליח להתחבר. בדקו שהשרת זמין ונסו שוב.", + "declined": "סירבתם בדפדפן, ולכן לא בוצעה התחברות.", + "too_many": "יותר מדי ניסיונות התחברות מהרשת הזו. המתינו כמה דקות ונסו שוב.", + "page_declined": "שום דבר לא הועבר. אפשר לסגור את הכרטיסייה." + }, "desktop_updates": { "title": "עדכונים", "version_label": "Bulwark {version}", @@ -6967,6 +6999,51 @@ "copy_failed": "לא ניתן להעתיק את הקובץ.", "reply_lost": "לא ניתן היה לשמור את התשובה שהקלדת. פתחו את ההודעה והשיבו משם." }, + "desktopEdit": { + "edit": "עריכה", + "edit_named": "עריכת {name}", + "opened": "{name} פתוח בתוכנה שלו. שמרו שם את השינויים, ואז תישאלו מה לעשות בהם.", + "offers_label": "קבצים מצורפים שנערכו", + "changed_title": "{name} השתנה", + "changed_body": "שמרתם שינויים בתוכנה אחרת. מה לעשות בהם?", + "reply": "צירוף לתשובה", + "new_message": "צירוף להודעה חדשה", + "open_draft": "פתיחת הטיוטה", + "save_copy": "שמירת עותק…", + "discard": "ביטול העריכות…", + "discard_title": "לבטל את העריכות ב{name}?", + "discard_detail": "העותק הערוך יימחק. הקובץ המצורף המקורי לא ישתנה.", + "discard_confirm": "ביטול העריכות", + "cancel": "ביטול", + "update": "עדכון הקובץ המצורף", + "no_app": "אין במחשב זה תוכנה שיכולה לפתוח את הקובץ.", + "blocked_type": "אי אפשר לפתוח סוג קובץ זה לעריכה, כי הוא עלול לרוץ כתוכנה.", + "blocked_policy": "הארגון שלכם לא מאפשר לשמור קבצים מצורפים במכשיר זה.", + "removed_policy": "הארגון שלכם כבר לא מאפשר זאת, ולכן העותק הערוך של {name} נמחק.", + "too_large": "הקובץ גדול מדי לפתיחה לעריכה.", + "too_many": "יותר מדי קבצים פתוחים לעריכה. סיימו קודם עם חלק מהם.", + "failed": "לא ניתן היה לפתוח את הקובץ לעריכה.", + "read_busy": "תוכנה אחרת עדיין משתמשת ב{name}. סגרו אותו שם ונסו שוב.", + "read_failed": "לא ניתן היה לקרוא את העותק הערוך של {name}.", + "send_stale_title": "ב{name} יש עריכות שאינן בהודעה זו", + "send_stale_detail": "אם תשלחו עכשיו, ההודעה תצא עם הגרסה הישנה. העריכות יישארו במחשב זה עד 30 יום, עד שתחליטו מה לעשות בהן.", + "send_stale_send": "לשלוח בכל זאת", + "send_stale_back": "חזרה", + "signout_title": "להתנתק ולמחוק את העריכות?", + "signout_confirm": "התנתקות", + "lock_note": "קבצים שפתחתם בעזרת עריכה נשארים פתוחים בתוכנה שלהם בזמן שהאפליקציה נעולה, ונשארים קריאים במחשב זה. אי אפשר לפתוח חדשים עד לביטול הנעילה.", + "changed_title_hidden": "קובץ מצורף שנערך נשמר", + "last_chance": "לא היה בשימוש 30 יום. העותק הזה יימחק מחר.", + "held_named": "{names}: עדיין פתוח בתוכנה אחרת, ולכן עדיין אי אפשר למחוק את העותק. סגרו אותו שם והוא יימחק.", + "held_unnamed": "עותק של קובץ מצורף עדיין פתוח בתוכנה אחרת ועדיין אי אפשר למחוק אותו. סגרו אותו שם והוא יימחק.", + "removed_policy_held": "הארגון שלכם כבר לא מאפשר זאת. {name} עדיין פתוח בתוכנה אחרת, ולכן אפשר למחוק את העותק רק אחרי שתסגרו אותו שם.", + "removed_policy_hidden": "הארגון שלכם כבר לא מאפשר לערוך קבצים מצורפים, ולכן עותק ערוך הוסר.", + "outside_note": "קובצי השחזור ורשימת הקבצים האחרונים של אותה תוכנה הם מחוץ למה שהאפליקציה יכולה לנקות.", + "clear_title": "למחוק את הקבצים המצורפים שערכתם?", + "clear_confirm": "מחיקה", + "signout_changed": "עדיין לא צורפו או נשמרו, ונמחקים עכשיו: {names}.", + "signout_held": "עדיין פתוחים בתוכנה אחרת, ולכן יימחקו רק אחרי שתסגרו אותם שם: {names}." + }, "desktopQuickCompose": { "label": "כתיבה מהירה", "menu": "כתיבה מהירה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 984e0aead..172fa6df6 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Nincs engedélyezve ezen az eszközön", "status_unsupported": "Ez a böngésző nem támogatja a Web Push-t", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "A háttérértesítések nem érhetők el a bemutatóban.", "status_busy": "Munka...", "enable": "Engedélyezés", "reenable": "Újraregisztráció", @@ -1766,7 +1767,12 @@ "confirm_revoke_message_this": "Ez az eszköz azonnal nem kap több háttérértesítést.", "shared_mailboxes_no_push": "A mások által Önnel megosztott postafiókok nem küldenek háttérértesítéseket.", "error_forbidden": "A levelezőszerver ennél a fióknál nem engedélyezi a háttérértesítéseket.", - "error_over_quota": "Ennek a fióknak túl sok push-regisztrációja van a levelezőszerveren (a korlát általában 15). Az értesítési beállításokban vonja vissza a már nem használt eszközöket, majd próbálja újra." + "error_over_quota": "Ennek a fióknak túl sok push-regisztrációja van a levelezőszerveren (a korlát általában 15). Az értesítési beállításokban vonja vissza a már nem használt eszközöket, majd próbálja újra.", + "error_blocked": "Az értesítések le vannak tiltva ezen a webhelyen. Engedélyezd őket a böngésző webhelybeállításaiban, majd próbáld újra.", + "error_not_granted": "Az értesítések nem lettek engedélyezve, ezért a push kikapcsolva marad.", + "error_verification_timeout": "A levelezőszerver nem erősítette meg időben ezt az eszközt, ezért a push kikapcsolva marad. Próbáld újra egy perc múlva.", + "error_relay": "A push relay most nem érhető el, ezért a push kikapcsolva marad. Próbáld újra később.", + "error_teardown": "A push ki van kapcsolva ebben a böngészőben, de a levelezőszerver vagy a relay nem volt elérhető. Néhány napig még érkezhetnek értesítések. Ha hamarabb le szeretnéd állítani őket, amint újra online vagy, kapcsold be a speciális beállításokat, és vond vissza ezt az eszközt a Regisztrált eszközök résznél." }, "push_all": { "title": "All accounts", @@ -6873,14 +6879,40 @@ "blocked_minutes": "Túl sok próbálkozás. Próbálja újra {count, plural, one {# perc} other {# perc}} múlva." }, "desktop_sign_in": { - "browser_button": "Bejelentkezés böngészővel", - "browser_waiting": "Fejezze be a bejelentkezést a böngészőben", - "browser_unavailable": "Ez a szerver nem kínál böngészős bejelentkezést. Használja a jelszavát.", - "browser_failed": "A böngészős bejelentkezés nem sikerült. Próbálja újra.", "server_needed": "Először adja meg a szerver címét.", "server_declined": "Nem engedélyezte ezt a szervert, ezért semmit sem küldtünk neki.", "server_invalid": "Ez a szervercím nem érvényes." }, + "desktop_browser_sign_in": { + "button": "Bejelentkezés a böngészővel…", + "waiting": "Várakozás a böngészőre…", + "waiting_hint": "Fejezze be ott a bejelentkezést. A jelszókezelője a böngészőben működik.", + "cancel": "Mégse", + "or_here": "vagy jelentkezzen be itt", + "timed_out": "A böngészős bejelentkezés túl sokáig tartott. Próbálja újra.", + "no_browser": "A Bulwark nem tudta megnyitni a böngészőt. Próbálja újra, vagy jelentkezzen be itt.", + "failed": "A böngészőn keresztüli bejelentkezés nem sikerült. Próbálja újra.", + "handoff_banner": "A számítógépen egy program kérte ezt a bejelentkezést a Bulwark alkalmazáshoz. Ez a böngésző kijelentkezve marad a Bulwarkból. Ha nem Ön indította, zárja be ezt a lapot.", + "consent_title": "Bejelentkezik a Bulwark alkalmazásba?", + "consent_description": "A számítógépen egy program a(z) {port} porton a(z) {username} bejelentkezését kéri. Csak akkor folytassa, ha az imént nyomta meg a „Bejelentkezés a böngészővel…” gombot az itteni Bulwark alkalmazásban. Ha nem, lehet, hogy valaki be akar jutni a fiókjába: válassza a Most nem lehetőséget.", + "consent_confirm": "Bejelentkezés az alkalmazásba", + "consent_cancel": "Most nem", + "page_done": "Bezárhatja ezt a lapot, és visszatérhet a Bulwarkhoz.", + "page_failed": "A bejelentkezés nem sikerült. Térjen vissza a Bulwarkhoz, és próbálja újra.", + "page_link_used": "Ez a hivatkozás csak egyszer működik. Térjen vissza a Bulwarkhoz, és kezdje újra.", + "page_title": "Bejelentkezés ide: {server}", + "page_intro": "Ez az oldal a számítógépen lévő Bulwark alkalmazástól származik. A jelszava a(z) {server} szerverre kerül, és sehova máshova.", + "page_username": "E-mail-cím vagy felhasználónév", + "page_password": "Jelszó", + "page_code": "Kétfaktoros kód", + "page_submit": "Bejelentkezés", + "page_wrong_password": "A szerver nem fogadta el ezt a felhasználónevet és jelszót.", + "page_code_needed": "Ehhez a fiókhoz kétfaktoros kód is kell. Adja meg újra a jelszavát és a kódot.", + "page_no_answer": "A Bulwark nem tudott bejelentkezni. Ellenőrizze, hogy a szerver elérhető-e, és próbálja újra.", + "declined": "A böngészőben nemet mondott, így nem történt bejelentkezés.", + "too_many": "Túl sok bejelentkezési kísérlet erről a hálózatról. Várjon néhány percet, és próbálja újra.", + "page_declined": "Semmi nem került átadásra. Bezárhatja ezt a lapot." + }, "desktop_updates": { "title": "Frissítések", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "A fájlt nem sikerült másolni.", "reply_lost": "A beírt válasz nem maradt meg. Nyissa meg az üzenetet, és válaszoljon ott." }, + "desktopEdit": { + "edit": "Szerkesztés", + "edit_named": "{name} szerkesztése", + "opened": "A(z) {name} meg van nyitva a programjában. Ott mentse a módosításokat, és megkérdezzük, mi legyen velük.", + "offers_label": "Szerkesztett mellékletek", + "changed_title": "A(z) {name} megváltozott", + "changed_body": "Módosításokat mentett egy másik programban. Mi legyen velük?", + "reply": "Csatolás válaszhoz", + "new_message": "Csatolás új üzenethez", + "open_draft": "Piszkozat megnyitása", + "save_copy": "Másolat mentése…", + "discard": "Módosítások elvetése…", + "discard_title": "Elveti a(z) {name} módosításait?", + "discard_detail": "A szerkesztett másolat törlődik. Az eredeti melléklet nem változik.", + "discard_confirm": "Elvetés", + "cancel": "Mégse", + "update": "Melléklet frissítése", + "no_app": "A számítógépen egyik program sem tudja megnyitni ezt a fájlt.", + "blocked_type": "Ez a fájltípus nem nyitható meg szerkesztésre, mert programként futhat.", + "blocked_policy": "A szervezete nem engedélyezi mellékletek mentését erre az eszközre.", + "removed_policy": "A szervezete ezt már nem engedélyezi, ezért a(z) {name} szerkesztett másolata törölve lett.", + "too_large": "Ez a fájl túl nagy ahhoz, hogy szerkesztésre megnyíljon.", + "too_many": "Túl sok fájl van megnyitva szerkesztésre. Előbb fejezzen be néhányat.", + "failed": "A fájlt nem sikerült megnyitni szerkesztésre.", + "read_busy": "Egy másik program még használja ezt: {name}. Zárja be ott, és próbálja újra.", + "read_failed": "A(z) {name} szerkesztett másolatát nem sikerült beolvasni.", + "send_stale_title": "A(z) {name} olyan módosításokat tartalmaz, amelyek nincsenek ebben az üzenetben", + "send_stale_detail": "Ha most küldi el, az üzenet a régebbi változattal megy el. A módosítások legfeljebb 30 napig ezen a számítógépen maradnak, amíg nem dönt róluk.", + "send_stale_send": "Küldés így is", + "send_stale_back": "Vissza", + "signout_title": "Kijelentkezik, és törli a módosításokat?", + "signout_confirm": "Kijelentkezés", + "lock_note": "A Szerkesztéssel megnyitott fájlok nyitva maradnak a programjukban, amíg az alkalmazás zárolva van, és olvashatók maradnak ezen a számítógépen. Újak csak a feloldás után nyithatók meg.", + "changed_title_hidden": "Egy szerkesztett melléklet mentve", + "last_chance": "30 napja nincs használva. Ez a másolat holnap törlődik.", + "held_named": "{names}: még nyitva van egy másik programban, ezért a másolat még nem törölhető. Zárja be ott, és törlődik.", + "held_unnamed": "Egy melléklet másolata még nyitva van egy másik programban, ezért még nem törölhető. Zárja be ott, és törlődik.", + "removed_policy_held": "A szervezete ezt már nem engedélyezi. A(z) {name} még nyitva van egy másik programban, ezért a másolat csak a bezárása után törölhető.", + "removed_policy_hidden": "A szervezete már nem engedélyezi a mellékletek szerkesztését, ezért egy szerkesztett másolat el lett távolítva.", + "outside_note": "Az adott program helyreállítási fájljait és legutóbbi fájljainak listáját ez az alkalmazás nem tudja törölni.", + "clear_title": "Törli a szerkesztett mellékleteket?", + "clear_confirm": "Törlés", + "signout_changed": "Még nincs csatolva vagy mentve, és most törlődik: {names}.", + "signout_held": "Még nyitva van egy másik programban, ezért csak a bezárása után törlődik: {names}." + }, "desktopQuickCompose": { "label": "Gyors levélírás", "menu": "Gyors levélírás", diff --git a/locales/it/common.json b/locales/it/common.json index 87166cde4..71f13cca0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Non abilitato su questo dispositivo", "status_unsupported": "Questo browser non supporta Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Le notifiche in background non sono disponibili nella demo.", "title": "Notifiche in background", "devices_title": "Dispositivi registrati", "devices_desc": "Tutti i dispositivi registrati per ricevere le notifiche in background di questo account. Revocandone uno, smette subito di riceverle.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "Questo dispositivo smetterà subito di ricevere le notifiche in background.", "shared_mailboxes_no_push": "Le caselle di posta che altri condividono con te non inviano notifiche in background.", "error_forbidden": "Il server di posta non consente le notifiche in background per questo account.", - "error_over_quota": "Questo account ha troppe registrazioni push sul server di posta (il limite di solito è 15). Revoca i dispositivi che non usi più nelle impostazioni delle notifiche, poi riprova." + "error_over_quota": "Questo account ha troppe registrazioni push sul server di posta (il limite di solito è 15). Revoca i dispositivi che non usi più nelle impostazioni delle notifiche, poi riprova.", + "error_blocked": "Le notifiche sono bloccate per questo sito. Consentile nelle impostazioni del sito del browser, poi riprova.", + "error_not_granted": "Le notifiche non sono state consentite, quindi le notifiche push restano disattivate.", + "error_verification_timeout": "Il server di posta non ha confermato questo dispositivo in tempo, quindi le notifiche push restano disattivate. Riprova tra un minuto.", + "error_relay": "Il relay delle notifiche non è raggiungibile in questo momento, quindi le notifiche push restano disattivate. Riprova più tardi.", + "error_teardown": "Le notifiche push sono disattivate in questo browser, ma il server di posta o il relay non erano raggiungibili. Potrebbero arrivare notifiche ancora per qualche giorno. Per fermarle prima, quando torni online attiva le impostazioni avanzate e revoca questo dispositivo in Dispositivi registrati." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Troppi tentativi. Riprova tra {count, plural, one {# minuto} other {# minuti}}." }, "desktop_sign_in": { - "browser_button": "Accedi con il browser", - "browser_waiting": "Completa l'accesso nel browser", - "browser_unavailable": "Questo server non offre l'accesso tramite browser. Usa la password.", - "browser_failed": "L'accesso tramite browser non è riuscito. Riprova.", "server_needed": "Inserisci prima l'indirizzo del server.", "server_declined": "Non hai autorizzato questo server, quindi non gli è stato inviato nulla.", "server_invalid": "Questo indirizzo del server non è valido." }, + "desktop_browser_sign_in": { + "button": "Accedi con il browser…", + "waiting": "In attesa del browser…", + "waiting_hint": "Completa l’accesso lì. Il tuo gestore di password funziona nel browser.", + "cancel": "Annulla", + "or_here": "oppure accedi qui", + "timed_out": "L’accesso nel browser ha richiesto troppo tempo. Riprova.", + "no_browser": "Bulwark non è riuscito ad aprire il browser. Riprova o accedi qui.", + "failed": "L’accesso tramite il browser non è riuscito. Riprova.", + "handoff_banner": "Un programma su questo computer ha chiesto questo accesso per l’app Bulwark. Questo browser resta disconnesso da Bulwark. Se non sei stato tu ad avviarlo, chiudi questa scheda.", + "consent_title": "Accedere all’app Bulwark?", + "consent_description": "Un programma su questo computer, alla porta {port}, chiede l’accesso per {username}. Continua solo se hai appena premuto «Accedi con il browser…» nell’app Bulwark qui. Altrimenti qualcuno potrebbe cercare di entrare nel tuo account: scegli Non ora.", + "consent_confirm": "Accedi all’app", + "consent_cancel": "Non ora", + "page_done": "Puoi chiudere questa scheda e tornare a Bulwark.", + "page_failed": "L’accesso non è riuscito. Torna a Bulwark e riprova.", + "page_link_used": "Questo link funziona una sola volta. Torna a Bulwark e ricomincia.", + "page_title": "Accedi a {server}", + "page_intro": "Questa pagina viene dall’app Bulwark su questo computer. La tua password va a {server} e da nessun’altra parte.", + "page_username": "Indirizzo e-mail o nome utente", + "page_password": "Password", + "page_code": "Codice a due fattori", + "page_submit": "Accedi", + "page_wrong_password": "Il server non ha accettato questo nome utente e questa password.", + "page_code_needed": "Questo account richiede anche un codice a due fattori. Inserisci di nuovo la password e il codice.", + "page_no_answer": "Bulwark non è riuscito ad accedere. Verifica che il server sia raggiungibile e riprova.", + "declined": "Hai detto no nel browser, quindi non è stato effettuato alcun accesso.", + "too_many": "Troppi tentativi di accesso da questa rete. Attendi qualche minuto e riprova.", + "page_declined": "Non è stato consegnato nulla. Puoi chiudere questa scheda." + }, "desktop_updates": { "title": "Aggiornamenti", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Impossibile copiare il file.", "reply_lost": "Non è stato possibile conservare la risposta digitata. Apri il messaggio e rispondi da lì." }, + "desktopEdit": { + "edit": "Modifica", + "edit_named": "Modifica {name}", + "opened": "{name} è aperto nel suo programma. Salva lì le modifiche e ti verrà chiesto cosa farne.", + "offers_label": "Allegati modificati", + "changed_title": "{name} è stato modificato", + "changed_body": "Hai salvato delle modifiche in un altro programma. Cosa vuoi farne?", + "reply": "Allega a una risposta", + "new_message": "Allega a un nuovo messaggio", + "open_draft": "Apri bozza", + "save_copy": "Salva una copia…", + "discard": "Scarta le modifiche…", + "discard_title": "Scartare le modifiche a {name}?", + "discard_detail": "La copia modificata viene eliminata. L’allegato originale non cambia.", + "discard_confirm": "Scarta", + "cancel": "Annulla", + "update": "Aggiorna allegato", + "no_app": "Nessun programma di questo computer può aprire questo file.", + "blocked_type": "Questo tipo di file non può essere aperto per la modifica, perché potrebbe essere eseguito come programma.", + "blocked_policy": "La tua organizzazione non consente di salvare allegati su questo dispositivo.", + "removed_policy": "La tua organizzazione non lo consente più, quindi la copia modificata di {name} è stata eliminata.", + "too_large": "Questo file è troppo grande per essere aperto e modificato.", + "too_many": "Ci sono troppi file aperti per la modifica. Prima terminane alcuni.", + "failed": "Non è stato possibile aprire il file per la modifica.", + "read_busy": "Un altro programma sta ancora usando {name}. Chiudilo lì e riprova.", + "read_failed": "Non è stato possibile leggere la copia modificata di {name}.", + "send_stale_title": "{name} ha modifiche che non sono in questo messaggio", + "send_stale_detail": "Se invii ora, il messaggio parte con la versione precedente. Le modifiche restano su questo computer fino a 30 giorni, finché non decidi cosa farne.", + "send_stale_send": "Invia comunque", + "send_stale_back": "Torna indietro", + "signout_title": "Uscire ed eliminare le modifiche?", + "signout_confirm": "Esci", + "lock_note": "I file aperti con Modifica restano aperti nel loro programma mentre l’app è bloccata, e restano leggibili su questo computer. Non se ne possono aprire altri finché non la sblocchi.", + "changed_title_hidden": "Un allegato modificato è stato salvato", + "last_chance": "Non usato da 30 giorni. Questa copia verrà eliminata domani.", + "held_named": "{names}: ancora aperto in un altro programma, quindi la copia non può ancora essere eliminata. Chiudilo lì e verrà eliminata.", + "held_unnamed": "La copia di un allegato è ancora aperta in un altro programma e non può ancora essere eliminata. Chiudila lì e verrà eliminata.", + "removed_policy_held": "La tua organizzazione non lo consente più. {name} è ancora aperto in un altro programma, quindi la copia non può essere eliminata finché non lo chiudi lì.", + "removed_policy_hidden": "La tua organizzazione non consente più di modificare gli allegati, quindi una copia modificata è stata rimossa.", + "outside_note": "I file di ripristino e l’elenco dei file recenti di quel programma sono fuori dalla portata di questa app.", + "clear_title": "Eliminare gli allegati modificati?", + "clear_confirm": "Elimina", + "signout_changed": "Non ancora allegati o salvati, ed eliminati ora: {names}.", + "signout_held": "Ancora aperti in un altro programma, quindi eliminati solo quando li chiudi lì: {names}." + }, "desktopQuickCompose": { "label": "Scrittura rapida", "menu": "Scrittura rapida", diff --git a/locales/ja/common.json b/locales/ja/common.json index 57f690cab..7d382e843 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "このデバイスでは有効になっていません", "status_unsupported": "このブラウザは Web Push をサポートしていません", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "デモではバックグラウンド通知を利用できません。", "title": "バックグラウンド通知", "devices_title": "登録済みのデバイス", "devices_desc": "このアカウントのバックグラウンド通知を受け取るよう登録されているデバイスの一覧です。取り消すと、そのデバイスはすぐに受信を停止します。", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "このデバイスはすぐにバックグラウンド通知の受信を停止します。", "shared_mailboxes_no_push": "他のユーザーから共有されたメールボックスでは、バックグラウンド通知は送信されません。", "error_forbidden": "メールサーバーは、このアカウントのバックグラウンド通知を許可していません。", - "error_over_quota": "このアカウントはメールサーバー上のプッシュ登録が多すぎます(上限は通常 15 件)。通知設定で使用していないデバイスを取り消してから、もう一度お試しください。" + "error_over_quota": "このアカウントはメールサーバー上のプッシュ登録が多すぎます(上限は通常 15 件)。通知設定で使用していないデバイスを取り消してから、もう一度お試しください。", + "error_blocked": "このサイトの通知はブロックされています。ブラウザのサイト設定で許可してから、もう一度お試しください。", + "error_not_granted": "通知が許可されなかったため、プッシュはオフのままです。", + "error_verification_timeout": "メールサーバーがこのデバイスを時間内に確認しなかったため、プッシュはオフのままです。1分後にもう一度お試しください。", + "error_relay": "プッシュリレーに接続できないため、プッシュはオフのままです。後でもう一度お試しください。", + "error_teardown": "このブラウザのプッシュはオフになりましたが、メールサーバーまたはリレーに接続できませんでした。数日間は通知が届く場合があります。早く止めるには、オンラインに戻ったら詳細設定をオンにして、登録済みデバイスでこのデバイスを取り消してください。" } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "試行回数が多すぎます。{count, plural, other {# 分}}後にもう一度お試しください。" }, "desktop_sign_in": { - "browser_button": "ブラウザでサインイン", - "browser_waiting": "ブラウザでサインインを完了してください", - "browser_unavailable": "このサーバーはブラウザでのサインインに対応していません。パスワードを使用してください。", - "browser_failed": "ブラウザでのサインインに失敗しました。もう一度お試しください。", "server_needed": "先にサーバーのアドレスを入力してください。", "server_declined": "このサーバーを許可しなかったため、何も送信していません。", "server_invalid": "このサーバーアドレスは正しくありません。" }, + "desktop_browser_sign_in": { + "button": "ブラウザーでサインイン…", + "waiting": "ブラウザーを待っています…", + "waiting_hint": "ブラウザーでサインインを完了してください。パスワードマネージャーはブラウザーで使えます。", + "cancel": "キャンセル", + "or_here": "またはここでサインイン", + "timed_out": "ブラウザーでのサインインに時間がかかりすぎました。もう一度お試しください。", + "no_browser": "Bulwark はブラウザーを開けませんでした。もう一度試すか、ここでサインインしてください。", + "failed": "ブラウザー経由でサインインできませんでした。もう一度お試しください。", + "handoff_banner": "このコンピューターのプログラムが Bulwark アプリのためにこのサインインを求めました。このブラウザーは Bulwark にサインインしません。自分で始めたのでなければ、このタブを閉じてください。", + "consent_title": "Bulwark アプリにサインインしますか?", + "consent_description": "このコンピューターのポート {port} のプログラムが、{username} のサインインを求めています。ここの Bulwark アプリで「ブラウザーでサインイン…」を押したばかりの場合のみ続行してください。そうでない場合は、誰かがアカウントに入ろうとしている可能性があります。「今はしない」を選んでください。", + "consent_confirm": "アプリにサインイン", + "consent_cancel": "今はしない", + "page_done": "このタブを閉じて Bulwark に戻れます。", + "page_failed": "サインインできませんでした。Bulwark に戻ってもう一度お試しください。", + "page_link_used": "このリンクは 1 回しか使えません。Bulwark に戻ってやり直してください。", + "page_title": "{server} にサインイン", + "page_intro": "このページは、このコンピューターの Bulwark アプリが表示しています。パスワードは {server} にだけ送られます。", + "page_username": "メールアドレスまたはユーザー名", + "page_password": "パスワード", + "page_code": "2 要素認証コード", + "page_submit": "サインイン", + "page_wrong_password": "サーバーはこのユーザー名とパスワードを受け付けませんでした。", + "page_code_needed": "このアカウントには 2 要素認証コードも必要です。パスワードをもう一度入力し、コードも入力してください。", + "page_no_answer": "Bulwark はサインインできませんでした。サーバーに接続できるか確認して、もう一度お試しください。", + "declined": "ブラウザーで断ったため、サインインしていません。", + "too_many": "このネットワークからのサインインの試行が多すぎます。数分待ってからもう一度お試しください。", + "page_declined": "何も渡していません。このタブを閉じてかまいません。" + }, "desktop_updates": { "title": "アップデート", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "ファイルをコピーできませんでした。", "reply_lost": "入力した返信を保持できませんでした。メッセージを開いて返信してください。" }, + "desktopEdit": { + "edit": "編集", + "edit_named": "{name} を編集", + "opened": "{name} を対応するプログラムで開きました。そこで変更を保存すると、どうするかを確認します。", + "offers_label": "編集した添付ファイル", + "changed_title": "{name} が変更されました", + "changed_body": "別のプログラムで変更を保存しました。どうしますか?", + "reply": "返信に添付", + "new_message": "新しいメッセージに添付", + "open_draft": "下書きを開く", + "save_copy": "コピーを保存…", + "discard": "編集内容を破棄…", + "discard_title": "{name} の編集内容を破棄しますか?", + "discard_detail": "編集したコピーは削除されます。元の添付ファイルは変わりません。", + "discard_confirm": "破棄", + "cancel": "キャンセル", + "update": "添付ファイルを更新", + "no_app": "このコンピューターには、このファイルを開けるプログラムがありません。", + "blocked_type": "この種類のファイルはプログラムとして実行される可能性があるため、編集用に開けません。", + "blocked_policy": "組織により、このデバイスへの添付ファイルの保存は許可されていません。", + "removed_policy": "組織で許可されなくなったため、{name} の編集済みコピーを削除しました。", + "too_large": "このファイルは大きすぎるため、編集用に開けません。", + "too_many": "編集用に開いているファイルが多すぎます。先にいくつか終わらせてください。", + "failed": "ファイルを編集用に開けませんでした。", + "read_busy": "別のプログラムが {name} をまだ使用しています。そちらで閉じてから、もう一度お試しください。", + "read_failed": "{name} の編集済みコピーを読み取れませんでした。", + "send_stale_title": "{name} には、このメッセージに含まれていない編集内容があります", + "send_stale_detail": "今送信すると、古いバージョンが送られます。編集内容は、どうするか決めるまで最大 30 日間このコンピューターに残ります。", + "send_stale_send": "このまま送信", + "send_stale_back": "戻る", + "signout_title": "サインアウトして編集内容を削除しますか?", + "signout_confirm": "サインアウト", + "lock_note": "「編集」で開いたファイルは、アプリのロック中もそのプログラムで開いたままになり、このコンピューター上で読み取れる状態のままです。新しいファイルはロック解除まで開けません。", + "changed_title_hidden": "編集した添付ファイルが保存されました", + "last_chance": "30 日間使用されていません。このコピーは明日削除されます。", + "held_named": "{names}: まだ別のプログラムで開かれているため、コピーを削除できません。そちらで閉じると削除されます。", + "held_unnamed": "添付ファイルのコピーがまだ別のプログラムで開かれているため、削除できません。そちらで閉じると削除されます。", + "removed_policy_held": "組織で許可されなくなりました。{name} はまだ別のプログラムで開かれているため、そちらで閉じるまでコピーを削除できません。", + "removed_policy_hidden": "組織で添付ファイルの編集が許可されなくなったため、編集済みコピーを削除しました。", + "outside_note": "そのプログラム自身の回復ファイルと最近使ったファイルの一覧は、このアプリでは消去できません。", + "clear_title": "編集した添付ファイルを削除しますか?", + "clear_confirm": "削除", + "signout_changed": "まだ添付も保存もされておらず、今削除されます: {names}。", + "signout_held": "まだ別のプログラムで開かれているため、そちらで閉じるまで削除されません: {names}。" + }, "desktopQuickCompose": { "label": "クイック作成", "menu": "クイック作成", diff --git a/locales/ko/common.json b/locales/ko/common.json index 67dfe6f99..9f9643ad0 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "이 기기에서 활성화되지 않음", "status_unsupported": "이 브라우저는 Web Push를 지원하지 않습니다", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "데모에서는 백그라운드 알림을 사용할 수 없습니다.", "title": "백그라운드 알림", "devices_title": "등록된 기기", "devices_desc": "이 계정의 백그라운드 알림을 받도록 등록된 모든 기기입니다. 해지하면 해당 기기는 즉시 수신을 멈추니다.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "이 기기는 즉시 백그라운드 알림 수신을 멈추게 됩니다.", "shared_mailboxes_no_push": "다른 사람이 공유한 메일함은 백그라운드 알림을 보내지 않습니다.", "error_forbidden": "메일 서버에서 이 계정의 백그라운드 알림을 허용하지 않습니다.", - "error_over_quota": "이 계정은 메일 서버에 푸시 등록이 너무 많습니다(한도는 보통 15개). 알림 설정에서 더 이상 사용하지 않는 기기를 해지한 후 다시 시도하세요." + "error_over_quota": "이 계정은 메일 서버에 푸시 등록이 너무 많습니다(한도는 보통 15개). 알림 설정에서 더 이상 사용하지 않는 기기를 해지한 후 다시 시도하세요.", + "error_blocked": "이 사이트의 알림이 차단되어 있습니다. 브라우저의 사이트 설정에서 허용한 후 다시 시도하세요.", + "error_not_granted": "알림이 허용되지 않아 푸시가 꺼진 상태로 유지됩니다.", + "error_verification_timeout": "메일 서버가 이 기기를 제때 확인하지 않아 푸시가 꺼진 상태로 유지됩니다. 1분 후에 다시 시도하세요.", + "error_relay": "지금은 푸시 릴레이에 연결할 수 없어 푸시가 꺼진 상태로 유지됩니다. 나중에 다시 시도하세요.", + "error_teardown": "이 브라우저에서 푸시가 꺼졌지만 메일 서버나 릴레이에 연결할 수 없었습니다. 며칠 동안 알림이 계속 올 수 있습니다. 더 빨리 중지하려면 다시 온라인 상태가 되었을 때 고급 설정을 켜고 등록된 기기에서 이 기기를 취소하세요." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "시도 횟수가 너무 많습니다. {count, plural, other {#분}} 후에 다시 시도하세요." }, "desktop_sign_in": { - "browser_button": "브라우저로 로그인", - "browser_waiting": "브라우저에서 로그인을 마치세요", - "browser_unavailable": "이 서버는 브라우저 로그인을 지원하지 않습니다. 비밀번호를 사용하세요.", - "browser_failed": "브라우저 로그인에 실패했습니다. 다시 시도하세요.", "server_needed": "먼저 서버 주소를 입력하세요.", "server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.", "server_invalid": "이 서버 주소는 올바르지 않습니다." }, + "desktop_browser_sign_in": { + "button": "브라우저로 로그인…", + "waiting": "브라우저를 기다리는 중…", + "waiting_hint": "브라우저에서 로그인을 마치세요. 비밀번호 관리자는 브라우저에서 사용할 수 있습니다.", + "cancel": "취소", + "or_here": "또는 여기에서 로그인", + "timed_out": "브라우저 로그인이 너무 오래 걸렸습니다. 다시 시도하세요.", + "no_browser": "Bulwark에서 브라우저를 열 수 없습니다. 다시 시도하거나 여기에서 로그인하세요.", + "failed": "브라우저를 통한 로그인에 실패했습니다. 다시 시도하세요.", + "handoff_banner": "이 컴퓨터의 프로그램이 Bulwark 앱을 위해 이 로그인을 요청했습니다. 이 브라우저는 Bulwark에 로그인되지 않습니다. 직접 시작하지 않았다면 이 탭을 닫으세요.", + "consent_title": "Bulwark 앱에 로그인할까요?", + "consent_description": "이 컴퓨터의 포트 {port}에 있는 프로그램이 {username}의 로그인을 요청하고 있습니다. 이 컴퓨터의 Bulwark 앱에서 방금 \"브라우저로 로그인…\"을 눌렀을 때만 계속하세요. 그렇지 않다면 누군가 계정에 들어가려는 것일 수 있습니다. \"나중에\"를 선택하세요.", + "consent_confirm": "앱에 로그인", + "consent_cancel": "나중에", + "page_done": "이 탭을 닫고 Bulwark로 돌아가도 됩니다.", + "page_failed": "로그인에 실패했습니다. Bulwark로 돌아가서 다시 시도하세요.", + "page_link_used": "이 링크는 한 번만 작동합니다. Bulwark로 돌아가서 다시 시작하세요.", + "page_title": "{server}에 로그인", + "page_intro": "이 페이지는 이 컴퓨터의 Bulwark 앱에서 제공합니다. 비밀번호는 {server}로만 전송됩니다.", + "page_username": "이메일 주소 또는 사용자 이름", + "page_password": "비밀번호", + "page_code": "2단계 인증 코드", + "page_submit": "로그인", + "page_wrong_password": "서버가 이 사용자 이름과 비밀번호를 받아들이지 않았습니다.", + "page_code_needed": "이 계정에는 2단계 인증 코드도 필요합니다. 비밀번호와 코드를 다시 입력하세요.", + "page_no_answer": "Bulwark에서 로그인할 수 없습니다. 서버에 연결할 수 있는지 확인하고 다시 시도하세요.", + "declined": "브라우저에서 거절하여 로그인하지 않았습니다.", + "too_many": "이 네트워크에서 로그인 시도가 너무 많습니다. 몇 분 기다린 후 다시 시도하세요.", + "page_declined": "아무것도 전달되지 않았습니다. 이 탭을 닫아도 됩니다." + }, "desktop_updates": { "title": "업데이트", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "파일을 복사할 수 없습니다.", "reply_lost": "입력한 답장을 유지할 수 없습니다. 메시지를 열어 답장하세요." }, + "desktopEdit": { + "edit": "편집", + "edit_named": "{name} 편집", + "opened": "{name}이(가) 해당 프로그램에서 열렸습니다. 그곳에서 변경 내용을 저장하면 어떻게 할지 묻습니다.", + "offers_label": "편집한 첨부 파일", + "changed_title": "{name}이(가) 변경되었습니다", + "changed_body": "다른 프로그램에서 변경 내용을 저장했습니다. 어떻게 할까요?", + "reply": "답장에 첨부", + "new_message": "새 메시지에 첨부", + "open_draft": "임시보관함 열기", + "save_copy": "사본 저장…", + "discard": "편집 내용 버리기…", + "discard_title": "{name}의 편집 내용을 버릴까요?", + "discard_detail": "편집한 사본이 삭제됩니다. 원본 첨부 파일은 바뀌지 않습니다.", + "discard_confirm": "버리기", + "cancel": "취소", + "update": "첨부 파일 업데이트", + "no_app": "이 컴퓨터에는 이 파일을 열 수 있는 프로그램이 없습니다.", + "blocked_type": "이 파일 형식은 프로그램으로 실행될 수 있어 편집용으로 열 수 없습니다.", + "blocked_policy": "조직에서 이 기기에 첨부 파일을 저장하는 것을 허용하지 않습니다.", + "removed_policy": "조직에서 더 이상 허용하지 않아 {name}의 편집한 사본을 삭제했습니다.", + "too_large": "이 파일은 너무 커서 편집용으로 열 수 없습니다.", + "too_many": "편집용으로 열린 파일이 너무 많습니다. 먼저 일부를 마무리하세요.", + "failed": "파일을 편집용으로 열 수 없습니다.", + "read_busy": "다른 프로그램이 아직 {name}을(를) 사용 중입니다. 그곳에서 닫고 다시 시도하세요.", + "read_failed": "{name}의 편집한 사본을 읽을 수 없습니다.", + "send_stale_title": "{name}에 이 메시지에 없는 편집 내용이 있습니다", + "send_stale_detail": "지금 보내면 이전 버전으로 전송됩니다. 편집 내용은 어떻게 할지 결정할 때까지 최대 30일 동안 이 컴퓨터에 남아 있습니다.", + "send_stale_send": "그래도 보내기", + "send_stale_back": "돌아가기", + "signout_title": "로그아웃하고 편집 내용을 삭제할까요?", + "signout_confirm": "로그아웃", + "lock_note": "편집으로 연 파일은 앱이 잠겨 있는 동안에도 해당 프로그램에서 계속 열려 있으며 이 컴퓨터에서 계속 읽을 수 있습니다. 잠금을 해제할 때까지 새 파일은 열 수 없습니다.", + "changed_title_hidden": "편집한 첨부 파일이 저장되었습니다", + "last_chance": "30일 동안 사용하지 않았습니다. 이 사본은 내일 삭제됩니다.", + "held_named": "{names}: 아직 다른 프로그램에서 열려 있어 사본을 삭제할 수 없습니다. 그곳에서 닫으면 삭제됩니다.", + "held_unnamed": "첨부 파일의 사본이 아직 다른 프로그램에서 열려 있어 삭제할 수 없습니다. 그곳에서 닫으면 삭제됩니다.", + "removed_policy_held": "조직에서 더 이상 허용하지 않습니다. {name}이(가) 아직 다른 프로그램에서 열려 있어, 그곳에서 닫아야 사본을 삭제할 수 있습니다.", + "removed_policy_hidden": "조직에서 첨부 파일 편집을 더 이상 허용하지 않아 편집한 사본을 제거했습니다.", + "outside_note": "그 프로그램의 복구 파일과 최근 파일 목록은 이 앱에서 지울 수 없습니다.", + "clear_title": "편집한 첨부 파일을 삭제할까요?", + "clear_confirm": "삭제", + "signout_changed": "아직 첨부하거나 저장하지 않았으며 지금 삭제됩니다: {names}.", + "signout_held": "아직 다른 프로그램에서 열려 있어 그곳에서 닫아야 삭제됩니다: {names}." + }, "desktopQuickCompose": { "label": "빠른 작성", "menu": "빠른 작성", diff --git a/locales/lv/common.json b/locales/lv/common.json index 71d547dc1..a4bc8ef83 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Nav iespējots šajā ierīcē", "status_unsupported": "Šī pārlūkprogramma neatbalsta Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Fona paziņojumi demonstrācijā nav pieejami.", "title": "Fona paziņojumi", "devices_title": "Reģistrētās ierīces", "devices_desc": "Visas ierīces, kas reģistrētas šī konta fona paziņojumu saņemšanai. Atsaucot ierīci, tā nekavējoties pārstāj tās saņemt.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "Šī ierīce nekavējoties pārstās saņemt fona paziņojumus.", "shared_mailboxes_no_push": "Pastkastes, ko ar jums kopīgo citi, nesūta fona paziņojumus.", "error_forbidden": "Pasta serveris šim kontam neatļauj fona paziņojumus.", - "error_over_quota": "Šim kontam pasta serverī ir pārāk daudz push reģistrāciju (ierobežojums parasti ir 15). Paziņojumu iestatījumos atsauciet ierīces, kuras vairs neizmantojat, un mēģiniet vēlreiz." + "error_over_quota": "Šim kontam pasta serverī ir pārāk daudz push reģistrāciju (ierobežojums parasti ir 15). Paziņojumu iestatījumos atsauciet ierīces, kuras vairs neizmantojat, un mēģiniet vēlreiz.", + "error_blocked": "Paziņojumi šai vietnei ir bloķēti. Atļaujiet tos pārlūka vietnes iestatījumos un mēģiniet vēlreiz.", + "error_not_granted": "Paziņojumi netika atļauti, tāpēc push paliek izslēgts.", + "error_verification_timeout": "Pasta serveris laikus neapstiprināja šo ierīci, tāpēc push paliek izslēgts. Mēģiniet vēlreiz pēc minūtes.", + "error_relay": "Push releju pašlaik nevar sasniegt, tāpēc push paliek izslēgts. Mēģiniet vēlreiz vēlāk.", + "error_teardown": "Push šajā pārlūkā ir izslēgts, bet pasta serveri vai releju nevarēja sasniegt. Paziņojumi vēl dažas dienas var turpināt pienākt. Lai tos apturētu ātrāk, kad atkal būsiet tiešsaistē, ieslēdziet papildu iestatījumus un atsauciet šo ierīci sadaļā Reģistrētās ierīces." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Pārāk daudz mēģinājumu. Mēģiniet vēlreiz pēc {count, plural, zero {# minūtēm} one {# minūtes} other {# minūtēm}}." }, "desktop_sign_in": { - "browser_button": "Pierakstīties ar pārlūku", - "browser_waiting": "Pabeidziet pierakstīšanos pārlūkā", - "browser_unavailable": "Šis serveris nepiedāvā pierakstīšanos ar pārlūku. Izmantojiet paroli.", - "browser_failed": "Pierakstīšanās ar pārlūku neizdevās. Mēģiniet vēlreiz.", "server_needed": "Vispirms ievadiet servera adresi.", "server_declined": "Jūs neatļāvāt šo serveri, tāpēc tam nekas netika nosūtīts.", "server_invalid": "Šī servera adrese nav derīga." }, + "desktop_browser_sign_in": { + "button": "Pieteikties ar pārlūku…", + "waiting": "Gaida pārlūku…", + "waiting_hint": "Pabeidziet pieteikšanos tur. Jūsu paroļu pārvaldnieks darbojas pārlūkā.", + "cancel": "Atcelt", + "or_here": "vai piesakieties šeit", + "timed_out": "Pieteikšanās pārlūkā aizņēma pārāk ilgu laiku. Mēģiniet vēlreiz.", + "no_browser": "Bulwark nevarēja atvērt pārlūku. Mēģiniet vēlreiz vai piesakieties šeit.", + "failed": "Pieteikšanās caur pārlūku neizdevās. Mēģiniet vēlreiz.", + "handoff_banner": "Programma šajā datorā pieprasīja šo pieteikšanos Bulwark lietotnei. Šis pārlūks paliek izrakstīts no Bulwark. Ja to nesākāt jūs, aizveriet šo cilni.", + "consent_title": "Pieteikties Bulwark lietotnē?", + "consent_description": "Programma šajā datorā, portā {port}, prasa pieteikšanos kontam {username}. Turpiniet tikai tad, ja tikko nospiedāt “Pieteikties ar pārlūku…” Bulwark lietotnē šajā datorā. Ja nē, iespējams, kāds mēģina piekļūt jūsu kontam: izvēlieties Ne tagad.", + "consent_confirm": "Pieteikties lietotnē", + "consent_cancel": "Ne tagad", + "page_done": "Varat aizvērt šo cilni un atgriezties Bulwark.", + "page_failed": "Pieteikšanās neizdevās. Atgriezieties Bulwark un mēģiniet vēlreiz.", + "page_link_used": "Šī saite darbojas tikai vienreiz. Atgriezieties Bulwark un sāciet no jauna.", + "page_title": "Pieteikties serverī {server}", + "page_intro": "Šī lapa nāk no Bulwark lietotnes šajā datorā. Jūsu parole nonāk serverī {server} un nekur citur.", + "page_username": "E-pasta adrese vai lietotājvārds", + "page_password": "Parole", + "page_code": "Divu faktoru kods", + "page_submit": "Pieteikties", + "page_wrong_password": "Serveris nepieņēma šo lietotājvārdu un paroli.", + "page_code_needed": "Šim kontam vajag arī divu faktoru kodu. Ievadiet paroli vēlreiz un kodu.", + "page_no_answer": "Bulwark nevarēja pieteikties. Pārbaudiet, vai serveris ir sasniedzams, un mēģiniet vēlreiz.", + "declined": "Jūs pārlūkā atteicāt, tāpēc pieteikšanās nenotika.", + "too_many": "Pārāk daudz pieteikšanās mēģinājumu no šī tīkla. Pagaidiet dažas minūtes un mēģiniet vēlreiz.", + "page_declined": "Nekas netika nodots. Varat aizvērt šo cilni." + }, "desktop_updates": { "title": "Atjauninājumi", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Failu neizdevās nokopēt.", "reply_lost": "Ievadīto atbildi neizdevās saglabāt. Atveriet ziņojumu un atbildiet tur." }, + "desktopEdit": { + "edit": "Rediģēt", + "edit_named": "Rediģēt {name}", + "opened": "{name} ir atvērts savā programmā. Saglabājiet izmaiņas tur, un jums jautās, ko ar tām darīt.", + "offers_label": "Rediģētie pielikumi", + "changed_title": "{name} ir mainīts", + "changed_body": "Jūs saglabājāt izmaiņas citā programmā. Ko ar tām darīt?", + "reply": "Pievienot atbildei", + "new_message": "Pievienot jaunam ziņojumam", + "open_draft": "Atvērt melnrakstu", + "save_copy": "Saglabāt kopiju…", + "discard": "Atmest izmaiņas…", + "discard_title": "Vai atmest {name} izmaiņas?", + "discard_detail": "Rediģētā kopija tiek izdzēsta. Sākotnējais pielikums nemainās.", + "discard_confirm": "Atmest", + "cancel": "Atcelt", + "update": "Atjaunināt pielikumu", + "no_app": "Neviena šī datora programma nevar atvērt šo failu.", + "blocked_type": "Šo faila veidu nevar atvērt rediģēšanai, jo tas var darboties kā programma.", + "blocked_policy": "Jūsu organizācija neļauj saglabāt pielikumus šajā ierīcē.", + "removed_policy": "Jūsu organizācija to vairs neļauj, tāpēc {name} rediģētā kopija ir izdzēsta.", + "too_large": "Šis fails ir pārāk liels, lai to atvērtu rediģēšanai.", + "too_many": "Rediģēšanai atvērts pārāk daudz failu. Vispirms pabeidziet dažus no tiem.", + "failed": "Failu neizdevās atvērt rediģēšanai.", + "read_busy": "Cita programma joprojām izmanto {name}. Aizveriet to tur un mēģiniet vēlreiz.", + "read_failed": "Neizdevās nolasīt {name} rediģēto kopiju.", + "send_stale_title": "{name} ir izmaiņas, kuru nav šajā ziņojumā", + "send_stale_detail": "Ja sūtīsiet tagad, ziņojums aizies ar vecāko versiju. Jūsu izmaiņas paliek šajā datorā līdz 30 dienām, līdz izlemsiet, ko ar tām darīt.", + "send_stale_send": "Tomēr sūtīt", + "send_stale_back": "Atpakaļ", + "signout_title": "Vai izrakstīties un dzēst izmaiņas?", + "signout_confirm": "Izrakstīties", + "lock_note": "Faili, kas atvērti ar Rediģēt, paliek atvērti savā programmā, kamēr lietotne ir bloķēta, un paliek lasāmi šajā datorā. Jaunus nevar atvērt, kamēr neatbloķēsiet.", + "changed_title_hidden": "Rediģēts pielikums ir saglabāts", + "last_chance": "30 dienas nav izmantots. Šī kopija rīt tiks izdzēsta.", + "held_named": "{names}: joprojām atvērts citā programmā, tāpēc kopiju vēl nevar izdzēst. Aizveriet to tur, un tā tiks izdzēsta.", + "held_unnamed": "Pielikuma kopija joprojām ir atvērta citā programmā, un to vēl nevar izdzēst. Aizveriet to tur, un tā tiks izdzēsta.", + "removed_policy_held": "Jūsu organizācija to vairs neļauj. {name} joprojām ir atvērts citā programmā, tāpēc kopiju varēs izdzēst tikai pēc tā aizvēršanas.", + "removed_policy_hidden": "Jūsu organizācija vairs neļauj rediģēt pielikumus, tāpēc rediģētā kopija ir noņemta.", + "outside_note": "Šīs programmas atkopšanas failus un neseno failu sarakstu šī lietotne nevar notīrīt.", + "clear_title": "Vai dzēst rediģētos pielikumus?", + "clear_confirm": "Dzēst", + "signout_changed": "Vēl nav pievienoti vai saglabāti, un tagad izdzēsti: {names}.", + "signout_held": "Joprojām atvērti citā programmā, tāpēc tiks izdzēsti tikai pēc aizvēršanas: {names}." + }, "desktopQuickCompose": { "label": "Ātrā rakstīšana", "menu": "Ātrā rakstīšana", diff --git a/locales/mn/common.json b/locales/mn/common.json index 884624adc..152574c4e 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Энэ төхөөрөмж дээр идэвхжээгүй", "status_unsupported": "Энэ хөтөч Web Push-ийг дэмждэггүй", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Демо хувилбарт арын мэдэгдэл ашиглах боломжгүй.", "status_busy": "Ажиллаж байна…", "enable": "Идэвхжүүлэх", "reenable": "Дахин бүртгүүлэх", @@ -1766,7 +1767,12 @@ "confirm_revoke_message_this": "Энэ төхөөрөмж тэр даруй араарх мэдэгдэл хүлээн авахаа болино.", "shared_mailboxes_no_push": "Бусдын тантай хуваалцсан шуудангийн хайрцгууд арын мэдэгдэл илгээдэггүй.", "error_forbidden": "Шуудангийн сервер энэ бүртгэлд арын мэдэгдэл зөвшөөрдөггүй.", - "error_over_quota": "Энэ бүртгэл шуудангийн сервер дээр хэт олон push бүртгэлтэй байна (хязгаар нь ихэвчлэн 15). Мэдэгдлийн тохиргооноос ашиглахаа больсон төхөөрөмжүүдээ цуцлаад дахин оролдоно уу." + "error_over_quota": "Энэ бүртгэл шуудангийн сервер дээр хэт олон push бүртгэлтэй байна (хязгаар нь ихэвчлэн 15). Мэдэгдлийн тохиргооноос ашиглахаа больсон төхөөрөмжүүдээ цуцлаад дахин оролдоно уу.", + "error_blocked": "Энэ сайтын мэдэгдлийг хориглосон байна. Хөтчийнхөө сайтын тохиргооноос зөвшөөрөөд дахин оролдоно уу.", + "error_not_granted": "Мэдэгдлийг зөвшөөрөөгүй тул push унтраалттай хэвээр байна.", + "error_verification_timeout": "Шуудангийн сервер энэ төхөөрөмжийг цагт нь баталгаажуулаагүй тул push унтраалттай хэвээр байна. Нэг минутын дараа дахин оролдоно уу.", + "error_relay": "Push релейд одоогоор холбогдож чадахгүй байгаа тул push унтраалттай хэвээр байна. Дараа дахин оролдоно уу.", + "error_teardown": "Энэ хөтөч дээр push унтарсан ч шуудангийн сервер эсвэл релейд холбогдож чадсангүй. Мэдэгдэл хэдэн өдрийн турш ирсээр байж магадгүй. Эртхэн зогсоохын тулд онлайн болсныхоо дараа нарийвчилсан тохиргоог асааж, Бүртгэлтэй төхөөрөмжүүд хэсгээс энэ төхөөрөмжийг цуцална уу." }, "push_all": { "title": "All accounts", @@ -6873,14 +6879,40 @@ "blocked_minutes": "Хэт олон оролдлого. {count, plural, one {# минутын} other {# минутын}} дараа дахин оролдоно уу." }, "desktop_sign_in": { - "browser_button": "Хөтчөөр нэвтрэх", - "browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу", - "browser_unavailable": "Энэ сервер хөтчөөр нэвтрэх боломжгүй. Нууц үгээ ашиглана уу.", - "browser_failed": "Хөтчөөр нэвтэрч чадсангүй. Дахин оролдоно уу.", "server_needed": "Эхлээд серверийн хаягаа оруулна уу.", "server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.", "server_invalid": "Энэ серверийн хаяг буруу байна." }, + "desktop_browser_sign_in": { + "button": "Хөтчөөрөө нэвтрэх…", + "waiting": "Хөтчийг хүлээж байна…", + "waiting_hint": "Тэнд нэвтрэлтээ дуусгана уу. Таны нууц үгийн менежер хөтөч дээр ажиллана.", + "cancel": "Цуцлах", + "or_here": "эсвэл энд нэвтэрнэ үү", + "timed_out": "Хөтчөөр нэвтрэх хэт удаан үргэлжилсэн. Дахин оролдоно уу.", + "no_browser": "Bulwark хөтчийг нээж чадсангүй. Дахин оролдох эсвэл энд нэвтэрнэ үү.", + "failed": "Хөтчөөр нэвтэрч чадсангүй. Дахин оролдоно уу.", + "handoff_banner": "Энэ компьютерийн програм Bulwark апп-д зориулж энэ нэвтрэлтийг хүссэн. Энэ хөтөч Bulwark-д нэвтрэхгүй хэвээр үлдэнэ. Та эхлүүлээгүй бол энэ табыг хаана уу.", + "consent_title": "Bulwark апп руу нэвтрэх үү?", + "consent_description": "Энэ компьютерийн {port} порт дээрх програм {username}-ийн нэвтрэлтийг хүсэж байна. Та дөнгөж сая энд байгаа Bulwark апп дээр \"Хөтчөөрөө нэвтрэх…\" дарсан бол л үргэлжлүүлнэ үү. Үгүй бол хэн нэгэн таны бүртгэлд орох гэж оролдож магадгүй: \"Одоо биш\"-ийг сонгоно уу.", + "consent_confirm": "Апп руу нэвтрэх", + "consent_cancel": "Одоо биш", + "page_done": "Энэ табыг хааж Bulwark руу буцаж болно.", + "page_failed": "Нэвтэрч чадсангүй. Bulwark руу буцаж дахин оролдоно уу.", + "page_link_used": "Энэ холбоос зөвхөн нэг удаа ажиллана. Bulwark руу буцаж дахин эхэлнэ үү.", + "page_title": "{server} руу нэвтрэх", + "page_intro": "Энэ хуудас энэ компьютер дээрх Bulwark апп-аас ирсэн. Таны нууц үг зөвхөн {server} руу очно.", + "page_username": "Имэйл хаяг эсвэл хэрэглэгчийн нэр", + "page_password": "Нууц үг", + "page_code": "Хоёр шатлалт код", + "page_submit": "Нэвтрэх", + "page_wrong_password": "Сервер энэ хэрэглэгчийн нэр, нууц үгийг хүлээн авсангүй.", + "page_code_needed": "Энэ бүртгэлд хоёр шатлалт код бас хэрэгтэй. Нууц үг болон кодоо дахин оруулна уу.", + "page_no_answer": "Bulwark нэвтэрч чадсангүй. Сервер холбогдох боломжтой эсэхийг шалгаад дахин оролдоно уу.", + "declined": "Та хөтөч дээр татгалзсан тул нэвтрээгүй.", + "too_many": "Энэ сүлжээнээс хэт олон удаа нэвтрэхийг оролдсон байна. Хэдэн минут хүлээгээд дахин оролдоно уу.", + "page_declined": "Юу ч шилжүүлээгүй. Энэ табыг хааж болно." + }, "desktop_updates": { "title": "Шинэчлэлт", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Файлыг хуулж чадсангүй.", "reply_lost": "Бичсэн хариуг хадгалж чадсангүй. Захиаг нээгээд тэндээс хариулна уу." }, + "desktopEdit": { + "edit": "Засах", + "edit_named": "{name} засах", + "opened": "{name} өөрийн программд нээлттэй байна. Өөрчлөлтөө тэнд хадгалаарай, тэгвэл юу хийхийг асууна.", + "offers_label": "Зассан хавсралтууд", + "changed_title": "{name} өөрчлөгдлөө", + "changed_body": "Та өөр программд өөрчлөлт хадгалсан байна. Үүнийг яах вэ?", + "reply": "Хариунд хавсаргах", + "new_message": "Шинэ зурваст хавсаргах", + "open_draft": "Ноорог нээх", + "save_copy": "Хуулбар хадгалах…", + "discard": "Засваруудыг хаях…", + "discard_title": "{name}-ийн засваруудыг хаях уу?", + "discard_detail": "Зассан хуулбар устана. Анхны хавсралт өөрчлөгдөхгүй.", + "discard_confirm": "Хаях", + "cancel": "Цуцлах", + "update": "Хавсралтыг шинэчлэх", + "no_app": "Энэ компьютерийн ямар ч программ энэ файлыг нээж чадахгүй.", + "blocked_type": "Энэ төрлийн файл программ болж ажиллаж болзошгүй тул засахаар нээх боломжгүй.", + "blocked_policy": "Танай байгууллага энэ төхөөрөмжид хавсралт хадгалахыг зөвшөөрдөггүй.", + "removed_policy": "Танай байгууллага үүнийг зөвшөөрөхөө больсон тул {name}-ийн зассан хуулбарыг устгалаа.", + "too_large": "Энэ файл засахаар нээхэд хэт том байна.", + "too_many": "Засахаар хэт олон файл нээлттэй байна. Эхлээд заримыг нь дуусгаарай.", + "failed": "Файлыг засахаар нээж чадсангүй.", + "read_busy": "Өөр программ {name}-г ашигласаар байна. Тэнд хаагаад дахин оролдоно уу.", + "read_failed": "{name}-ийн зассан хуулбарыг уншиж чадсангүй.", + "send_stale_title": "{name}-д энэ зурваст ороогүй засвар байна", + "send_stale_detail": "Одоо илгээвэл зурвас хуучин хувилбартайгаа явна. Таны засварууд шийдвэр гаргах хүртэл 30 хүртэл хоног энэ компьютерт үлдэнэ.", + "send_stale_send": "Ямар ч байсан илгээх", + "send_stale_back": "Буцах", + "signout_title": "Гарч, засваруудаа устгах уу?", + "signout_confirm": "Гарах", + "lock_note": "Засах-аар нээсэн файлууд апп түгжээтэй үед ч өөрийн программд нээлттэй хэвээр байх бөгөөд энэ компьютерт уншигдах боломжтой хэвээр байна. Түгжээг тайлах хүртэл шинээр нээх боломжгүй.", + "changed_title_hidden": "Зассан хавсралт хадгалагдлаа", + "last_chance": "30 хоног ашиглаагүй. Энэ хуулбар маргааш устана.", + "held_named": "{names}: өөр программд нээлттэй хэвээр тул хуулбарыг одоохондоо устгах боломжгүй. Тэнд хаавал устана.", + "held_unnamed": "Хавсралтын хуулбар өөр программд нээлттэй хэвээр тул одоохондоо устгах боломжгүй. Тэнд хаавал устана.", + "removed_policy_held": "Танай байгууллага үүнийг зөвшөөрөхөө больсон. {name} өөр программд нээлттэй хэвээр тул тэнд хаасны дараа л хуулбарыг устгана.", + "removed_policy_hidden": "Танай байгууллага хавсралт засахыг зөвшөөрөхөө больсон тул зассан хуулбарыг хаслаа.", + "outside_note": "Тэр программын сэргээх файлууд болон сүүлийн файлын жагсаалтыг энэ апп цэвэрлэж чадахгүй.", + "clear_title": "Зассан хавсралтуудаа устгах уу?", + "clear_confirm": "Устгах", + "signout_changed": "Хавсаргаагүй, хадгалаагүй бөгөөд одоо устгагдана: {names}.", + "signout_held": "Өөр программд нээлттэй хэвээр тул тэнд хаасны дараа л устана: {names}." + }, "desktopQuickCompose": { "label": "Хурдан бичих", "menu": "Хурдан бичих", diff --git a/locales/nb/common.json b/locales/nb/common.json index 20d450b83..9a6c14f76 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Ikke aktivert på denne enheten", "status_unsupported": "Denne nettleseren støtter ikke Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Bakgrunnsvarsler er ikke tilgjengelige i demoen.", "status_busy": "Behandler...", "enable": "Aktiver", "reenable": "Registrer på nytt", @@ -1766,7 +1767,12 @@ "confirm_revoke_message_this": "Denne enheten slutter umiddelbart å motta bakgrunnsvarsler.", "shared_mailboxes_no_push": "Postbokser som andre deler med deg, sender ikke bakgrunnsvarsler.", "error_forbidden": "E-postserveren tillater ikke bakgrunnsvarsler for denne kontoen.", - "error_over_quota": "Denne kontoen har for mange push-registreringer på e-postserveren (grensen er vanligvis 15). Tilbakekall enheter du ikke lenger bruker i varslingsinnstillingene, og prøv igjen." + "error_over_quota": "Denne kontoen har for mange push-registreringer på e-postserveren (grensen er vanligvis 15). Tilbakekall enheter du ikke lenger bruker i varslingsinnstillingene, og prøv igjen.", + "error_blocked": "Varsler er blokkert for dette nettstedet. Tillat dem i nettleserens nettstedsinnstillinger, og prøv igjen.", + "error_not_granted": "Varsler ble ikke tillatt, så push forblir av.", + "error_verification_timeout": "E-postserveren bekreftet ikke denne enheten i tide, så push forblir av. Prøv igjen om et minutt.", + "error_relay": "Push-reléet kan ikke nås akkurat nå, så push forblir av. Prøv igjen senere.", + "error_teardown": "Push er av i denne nettleseren, men e-postserveren eller reléet kunne ikke nås. Varsler kan fortsette å komme i noen dager. For å stoppe dem tidligere, slå på avanserte innstillinger når du er på nett igjen, og tilbakekall denne enheten under Registrerte enheter." }, "push_all": { "title": "All accounts", @@ -6873,14 +6879,40 @@ "blocked_minutes": "For mange forsøk. Prøv igjen om {count, plural, one {# minutt} other {# minutter}}." }, "desktop_sign_in": { - "browser_button": "Logg på med nettleseren", - "browser_waiting": "Fullfør påloggingen i nettleseren", - "browser_unavailable": "Denne serveren tilbyr ikke pålogging via nettleser. Bruk passordet ditt.", - "browser_failed": "Pålogging via nettleseren mislyktes. Prøv igjen.", "server_needed": "Skriv inn serveradressen først.", "server_declined": "Du tillot ikke denne serveren, så ingenting ble sendt til den.", "server_invalid": "Denne serveradressen er ikke gyldig." }, + "desktop_browser_sign_in": { + "button": "Logg inn med nettleseren…", + "waiting": "Venter på nettleseren…", + "waiting_hint": "Fullfør innloggingen der. Passordbehandleren din virker i nettleseren.", + "cancel": "Avbryt", + "or_here": "eller logg inn her", + "timed_out": "Innloggingen i nettleseren tok for lang tid. Prøv igjen.", + "no_browser": "Bulwark kunne ikke åpne nettleseren. Prøv igjen, eller logg inn her.", + "failed": "Innlogging via nettleseren virket ikke. Prøv igjen.", + "handoff_banner": "Et program på denne datamaskinen ba om denne innloggingen for Bulwark-appen. Denne nettleseren forblir logget ut av Bulwark. Hvis du ikke startet dette, lukk denne fanen.", + "consent_title": "Logge inn i Bulwark-appen?", + "consent_description": "Et program på denne datamaskinen, på port {port}, ber om innloggingen for {username}. Fortsett bare hvis du nettopp trykket på «Logg inn med nettleseren…» i Bulwark-appen her. Hvis ikke, prøver kanskje noen å komme inn på kontoen din: velg Ikke nå.", + "consent_confirm": "Logg inn i appen", + "consent_cancel": "Ikke nå", + "page_done": "Du kan lukke denne fanen og gå tilbake til Bulwark.", + "page_failed": "Innloggingen virket ikke. Gå tilbake til Bulwark og prøv igjen.", + "page_link_used": "Denne lenken virker bare én gang. Gå tilbake til Bulwark og start på nytt.", + "page_title": "Logg inn på {server}", + "page_intro": "Denne siden kommer fra Bulwark-appen på denne datamaskinen. Passordet ditt går til {server} og ingen andre steder.", + "page_username": "E-postadresse eller brukernavn", + "page_password": "Passord", + "page_code": "Tofaktorkode", + "page_submit": "Logg inn", + "page_wrong_password": "Serveren godtok ikke dette brukernavnet og passordet.", + "page_code_needed": "Denne kontoen trenger også en tofaktorkode. Skriv inn passordet på nytt og koden.", + "page_no_answer": "Bulwark kunne ikke logge inn. Sjekk at serveren kan nås, og prøv igjen.", + "declined": "Du sa nei i nettleseren, så ingenting ble logget inn.", + "too_many": "For mange innloggingsforsøk fra dette nettverket. Vent noen minutter og prøv igjen.", + "page_declined": "Ingenting ble overlevert. Du kan lukke denne fanen." + }, "desktop_updates": { "title": "Oppdateringer", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Filen kunne ikke kopieres.", "reply_lost": "Svaret du skrev, kunne ikke beholdes. Åpne meldingen og svar der." }, + "desktopEdit": { + "edit": "Rediger", + "edit_named": "Rediger {name}", + "opened": "{name} er åpen i programmet sitt. Lagre endringene der, så blir du spurt om hva som skal skje med dem.", + "offers_label": "Redigerte vedlegg", + "changed_title": "{name} ble endret", + "changed_body": "Du lagret endringer i et annet program. Hva skal skje med dem?", + "reply": "Legg ved et svar", + "new_message": "Legg ved en ny melding", + "open_draft": "Åpne utkast", + "save_copy": "Lagre en kopi…", + "discard": "Forkast endringer…", + "discard_title": "Vil du forkaste endringene i {name}?", + "discard_detail": "Den redigerte kopien slettes. Det opprinnelige vedlegget endres ikke.", + "discard_confirm": "Forkast", + "cancel": "Avbryt", + "update": "Oppdater vedlegg", + "no_app": "Ingen programmer på denne datamaskinen kan åpne filen.", + "blocked_type": "Denne filtypen kan ikke åpnes for redigering fordi den kan kjøre som et program.", + "blocked_policy": "Organisasjonen din tillater ikke lagring av vedlegg på denne enheten.", + "removed_policy": "Organisasjonen din tillater ikke dette lenger, så den redigerte kopien av {name} ble slettet.", + "too_large": "Filen er for stor til å åpnes for redigering.", + "too_many": "For mange filer er åpne for redigering. Gjør ferdig noen av dem først.", + "failed": "Filen kunne ikke åpnes for redigering.", + "read_busy": "Et annet program bruker fortsatt {name}. Lukk den der og prøv igjen.", + "read_failed": "Den redigerte kopien av {name} kunne ikke leses.", + "send_stale_title": "{name} har endringer som ikke er i denne meldingen", + "send_stale_detail": "Hvis du sender nå, går meldingen med den eldre versjonen. Endringene blir på denne datamaskinen i opptil 30 dager, til du bestemmer hva som skal skje med dem.", + "send_stale_send": "Send likevel", + "send_stale_back": "Tilbake", + "signout_title": "Vil du logge ut og slette endringene?", + "signout_confirm": "Logg ut", + "lock_note": "Filer du åpnet med Rediger, forblir åpne i programmet sitt mens appen er låst, og kan fortsatt leses på denne datamaskinen. Nye kan ikke åpnes før du låser opp.", + "changed_title_hidden": "Et redigert vedlegg ble lagret", + "last_chance": "Ikke brukt på 30 dager. Denne kopien slettes i morgen.", + "held_named": "{names}: fortsatt åpen i et annet program, så kopien kan ikke slettes ennå. Lukk den der, så slettes den.", + "held_unnamed": "Kopien av et vedlegg er fortsatt åpen i et annet program og kan ikke slettes ennå. Lukk den der, så slettes den.", + "removed_policy_held": "Organisasjonen din tillater ikke dette lenger. {name} er fortsatt åpen i et annet program, så kopien kan ikke slettes før du lukker den der.", + "removed_policy_hidden": "Organisasjonen din tillater ikke lenger redigering av vedlegg, så en redigert kopi ble fjernet.", + "outside_note": "Programmets egne gjenopprettingsfiler og liste over nylige filer kan ikke denne appen fjerne.", + "clear_title": "Vil du slette de redigerte vedleggene?", + "clear_confirm": "Slett", + "signout_changed": "Ikke lagt ved eller lagret ennå, og slettet nå: {names}.", + "signout_held": "Fortsatt åpne i et annet program, så de slettes først når du lukker dem der: {names}." + }, "desktopQuickCompose": { "label": "Rask skriving", "menu": "Rask skriving", diff --git a/locales/nl/common.json b/locales/nl/common.json index 42f59a638..3c57a4007 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Niet ingeschakeld op dit apparaat", "status_unsupported": "Deze browser ondersteunt geen Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Achtergrondmeldingen zijn niet beschikbaar in de demo.", "title": "Achtergrondmeldingen", "devices_title": "Geregistreerde apparaten", "devices_desc": "Alle apparaten die achtergrondmeldingen voor dit account ontvangen. Trek je er een in, dan stopt de ontvangst meteen.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "Dit apparaat ontvangt meteen geen achtergrondmeldingen meer.", "shared_mailboxes_no_push": "Mailboxen die anderen met u delen, sturen geen achtergrondmeldingen.", "error_forbidden": "De mailserver staat voor dit account geen achtergrondmeldingen toe.", - "error_over_quota": "Dit account heeft te veel pushregistraties op de mailserver (de limiet is meestal 15). Trek in de meldingsinstellingen apparaten in die u niet meer gebruikt en probeer het opnieuw." + "error_over_quota": "Dit account heeft te veel pushregistraties op de mailserver (de limiet is meestal 15). Trek in de meldingsinstellingen apparaten in die u niet meer gebruikt en probeer het opnieuw.", + "error_blocked": "Meldingen zijn geblokkeerd voor deze site. Sta ze toe in de site-instellingen van uw browser en probeer het opnieuw.", + "error_not_granted": "Meldingen zijn niet toegestaan, dus push blijft uit.", + "error_verification_timeout": "De mailserver heeft dit apparaat niet op tijd bevestigd, dus push blijft uit. Probeer het over een minuut opnieuw.", + "error_relay": "De pushrelay is nu niet bereikbaar, dus push blijft uit. Probeer het later opnieuw.", + "error_teardown": "Push staat uit in deze browser, maar de mailserver of de relay was niet bereikbaar. Er kunnen nog een paar dagen meldingen binnenkomen. Wilt u ze eerder stoppen, zet dan de geavanceerde instellingen aan zodra u weer online bent en trek dit apparaat in onder Geregistreerde apparaten." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Te veel pogingen. Probeer het over {count, plural, one {# minuut} other {# minuten}} opnieuw." }, "desktop_sign_in": { - "browser_button": "Inloggen met je browser", - "browser_waiting": "Rond het inloggen af in je browser", - "browser_unavailable": "Deze server biedt geen inloggen via een browser. Gebruik je wachtwoord.", - "browser_failed": "Inloggen via de browser is niet gelukt. Probeer het opnieuw.", "server_needed": "Vul eerst het adres van de server in.", "server_declined": "Je hebt deze server niet toegestaan, dus er is niets naartoe gestuurd.", "server_invalid": "Dit serveradres is niet geldig." }, + "desktop_browser_sign_in": { + "button": "Inloggen met je browser…", + "waiting": "Wachten op je browser…", + "waiting_hint": "Rond het inloggen daar af. Je wachtwoordbeheerder werkt in je browser.", + "cancel": "Annuleren", + "or_here": "of log hier in", + "timed_out": "Inloggen in de browser duurde te lang. Probeer het opnieuw.", + "no_browser": "Bulwark kon je browser niet openen. Probeer het opnieuw of log hier in.", + "failed": "Inloggen via je browser is niet gelukt. Probeer het opnieuw.", + "handoff_banner": "Een programma op deze computer heeft om deze aanmelding gevraagd voor de Bulwark-app. Deze browser blijft uitgelogd bij Bulwark. Heb je dit niet gestart, sluit dan dit tabblad.", + "consent_title": "Inloggen bij de Bulwark-app?", + "consent_description": "Een programma op deze computer, op poort {port}, vraagt om de aanmelding van {username}. Ga alleen verder als je zojuist op \"Inloggen met je browser…\" hebt geklikt in de Bulwark-app hier. Zo niet, dan probeert iemand misschien in je account te komen: kies Niet nu.", + "consent_confirm": "Inloggen bij de app", + "consent_cancel": "Niet nu", + "page_done": "Je kunt dit tabblad sluiten en teruggaan naar Bulwark.", + "page_failed": "Inloggen is niet gelukt. Ga terug naar Bulwark en probeer het opnieuw.", + "page_link_used": "Deze link werkt maar één keer. Ga terug naar Bulwark en begin opnieuw.", + "page_title": "Inloggen bij {server}", + "page_intro": "Deze pagina komt van de Bulwark-app op deze computer. Je wachtwoord gaat naar {server} en nergens anders heen.", + "page_username": "E-mailadres of gebruikersnaam", + "page_password": "Wachtwoord", + "page_code": "Tweefactorcode", + "page_submit": "Inloggen", + "page_wrong_password": "De server heeft deze gebruikersnaam en dit wachtwoord niet geaccepteerd.", + "page_code_needed": "Dit account heeft ook een tweefactorcode nodig. Voer je wachtwoord opnieuw in en de code.", + "page_no_answer": "Bulwark kon niet inloggen. Controleer of de server bereikbaar is en probeer het opnieuw.", + "declined": "Je hebt in je browser nee gezegd, dus er is niet ingelogd.", + "too_many": "Te veel inlogpogingen vanaf dit netwerk. Wacht een paar minuten en probeer het opnieuw.", + "page_declined": "Er is niets overgedragen. Je kunt dit tabblad sluiten." + }, "desktop_updates": { "title": "Updates", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Het bestand kon niet worden gekopieerd.", "reply_lost": "Je getypte antwoord kon niet worden bewaard. Open het bericht en antwoord daar." }, + "desktopEdit": { + "edit": "Bewerken", + "edit_named": "{name} bewerken", + "opened": "{name} is geopend in zijn programma. Sla uw wijzigingen daar op, dan wordt u gevraagd wat ermee moet gebeuren.", + "offers_label": "Bewerkte bijlagen", + "changed_title": "{name} is gewijzigd", + "changed_body": "U hebt wijzigingen opgeslagen in een ander programma. Wat moet daarmee gebeuren?", + "reply": "Bijvoegen bij een antwoord", + "new_message": "Bijvoegen bij een nieuw bericht", + "open_draft": "Concept openen", + "save_copy": "Kopie opslaan…", + "discard": "Wijzigingen weggooien…", + "discard_title": "Uw wijzigingen in {name} weggooien?", + "discard_detail": "De bewerkte kopie wordt verwijderd. De oorspronkelijke bijlage verandert niet.", + "discard_confirm": "Weggooien", + "cancel": "Annuleren", + "update": "Bijlage bijwerken", + "no_app": "Geen enkel programma op deze computer kan dit bestand openen.", + "blocked_type": "Dit type bestand kan niet worden geopend om te bewerken, omdat het als programma kan worden uitgevoerd.", + "blocked_policy": "Uw organisatie staat niet toe dat bijlagen op dit apparaat worden opgeslagen.", + "removed_policy": "Uw organisatie staat dit niet meer toe, dus de bewerkte kopie van {name} is verwijderd.", + "too_large": "Dit bestand is te groot om te openen en te bewerken.", + "too_many": "Er zijn te veel bestanden geopend om te bewerken. Rond er eerst een paar af.", + "failed": "Het bestand kon niet worden geopend om te bewerken.", + "read_busy": "Een ander programma gebruikt {name} nog. Sluit het daar en probeer het opnieuw.", + "read_failed": "De bewerkte kopie van {name} kon niet worden gelezen.", + "send_stale_title": "{name} heeft wijzigingen die niet in dit bericht staan", + "send_stale_detail": "Als u nu verzendt, gaat het bericht met de oudere versie mee. Uw wijzigingen blijven tot 30 dagen op deze computer, tot u beslist wat ermee moet gebeuren.", + "send_stale_send": "Toch verzenden", + "send_stale_back": "Terug", + "signout_title": "Afmelden en uw wijzigingen verwijderen?", + "signout_confirm": "Afmelden", + "lock_note": "Bestanden die u met Bewerken hebt geopend, blijven in hun programma geopend terwijl de app is vergrendeld, en blijven leesbaar op deze computer. Nieuwe kunnen pas na het ontgrendelen worden geopend.", + "changed_title_hidden": "Een bewerkte bijlage is opgeslagen", + "last_chance": "30 dagen niet gebruikt. Deze kopie wordt morgen verwijderd.", + "held_named": "{names}: nog geopend in een ander programma, dus de kopie kan nog niet worden verwijderd. Sluit het daar, dan wordt die verwijderd.", + "held_unnamed": "De kopie van een bijlage is nog geopend in een ander programma en kan nog niet worden verwijderd. Sluit die daar, dan wordt die verwijderd.", + "removed_policy_held": "Uw organisatie staat dit niet meer toe. {name} is nog geopend in een ander programma, dus de kopie kan pas worden verwijderd als u het daar sluit.", + "removed_policy_hidden": "Uw organisatie staat het bewerken van bijlagen niet meer toe, dus een bewerkte kopie is verwijderd.", + "outside_note": "De herstelbestanden en de lijst met recente bestanden van dat programma kan deze app niet wissen.", + "clear_title": "Uw bewerkte bijlagen verwijderen?", + "clear_confirm": "Verwijderen", + "signout_changed": "Nog niet bijgevoegd of opgeslagen, en nu verwijderd: {names}.", + "signout_held": "Nog geopend in een ander programma, dus pas verwijderd als u ze daar sluit: {names}." + }, "desktopQuickCompose": { "label": "Snel opstellen", "menu": "Snel opstellen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 2932b5663..20bd079ae 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Niewłączone na tym urządzeniu", "status_unsupported": "Ta przeglądarka nie obsługuje Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Powiadomienia w tle nie są dostępne w wersji demonstracyjnej.", "title": "Powiadomienia w tle", "devices_title": "Zarejestrowane urządzenia", "devices_desc": "Wszystkie urządzenia zarejestrowane do odbioru powiadomień w tle dla tego konta. Unieważnienie natychmiast wstrzymuje ich odbiór.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "To urządzenie natychmiast przestanie odbierać powiadomienia w tle.", "shared_mailboxes_no_push": "Skrzynki pocztowe udostępnione ci przez innych nie wysyłają powiadomień w tle.", "error_forbidden": "Serwer poczty nie zezwala na powiadomienia w tle dla tego konta.", - "error_over_quota": "To konto ma zbyt wiele rejestracji push na serwerze poczty (limit wynosi zwykle 15). Unieważnij nieużywane urządzenia w ustawieniach powiadomień i spróbuj ponownie." + "error_over_quota": "To konto ma zbyt wiele rejestracji push na serwerze poczty (limit wynosi zwykle 15). Unieważnij nieużywane urządzenia w ustawieniach powiadomień i spróbuj ponownie.", + "error_blocked": "Powiadomienia są zablokowane dla tej witryny. Zezwól na nie w ustawieniach witryny w przeglądarce i spróbuj ponownie.", + "error_not_granted": "Nie zezwolono na powiadomienia, więc push pozostaje wyłączony.", + "error_verification_timeout": "Serwer poczty nie potwierdził tego urządzenia na czas, więc push pozostaje wyłączony. Spróbuj ponownie za minutę.", + "error_relay": "Przekaźnik push jest teraz niedostępny, więc push pozostaje wyłączony. Spróbuj ponownie później.", + "error_teardown": "Push jest wyłączony w tej przeglądarce, ale serwer poczty lub przekaźnik był niedostępny. Powiadomienia mogą przychodzić jeszcze przez kilka dni. Aby zatrzymać je wcześniej, gdy znów będziesz online, włącz ustawienia zaawansowane i unieważnij to urządzenie w sekcji Zarejestrowane urządzenia." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Zbyt wiele prób. Spróbuj za {count, plural, one {# minutę} few {# minuty} many {# minut} other {# minuty}}." }, "desktop_sign_in": { - "browser_button": "Zaloguj się w przeglądarce", - "browser_waiting": "Dokończ logowanie w przeglądarce", - "browser_unavailable": "Ten serwer nie oferuje logowania przez przeglądarkę. Użyj hasła.", - "browser_failed": "Logowanie przez przeglądarkę nie powiodło się. Spróbuj ponownie.", "server_needed": "Najpierw wpisz adres serwera.", "server_declined": "Ten serwer nie został dozwolony, więc nic do niego nie wysłano.", "server_invalid": "Ten adres serwera jest nieprawidłowy." }, + "desktop_browser_sign_in": { + "button": "Zaloguj się w przeglądarce…", + "waiting": "Czekanie na przeglądarkę…", + "waiting_hint": "Dokończ logowanie w przeglądarce. Działa tam Twój menedżer haseł.", + "cancel": "Anuluj", + "or_here": "lub zaloguj się tutaj", + "timed_out": "Logowanie w przeglądarce trwało zbyt długo. Spróbuj ponownie.", + "no_browser": "Bulwark nie może otworzyć przeglądarki. Spróbuj ponownie lub zaloguj się tutaj.", + "failed": "Logowanie przez przeglądarkę nie powiodło się. Spróbuj ponownie.", + "handoff_banner": "Program na tym komputerze poprosił o to logowanie dla aplikacji Bulwark. Ta przeglądarka pozostaje wylogowana z Bulwark. Jeśli to nie Ty, zamknij tę kartę.", + "consent_title": "Zalogować się w aplikacji Bulwark?", + "consent_description": "Program na tym komputerze, na porcie {port}, prosi o logowanie dla {username}. Kontynuuj tylko wtedy, gdy przed chwilą kliknięto „Zaloguj się w przeglądarce…” w aplikacji Bulwark na tym komputerze. Jeśli nie, ktoś może próbować dostać się na Twoje konto: wybierz Nie teraz.", + "consent_confirm": "Zaloguj się w aplikacji", + "consent_cancel": "Nie teraz", + "page_done": "Możesz zamknąć tę kartę i wrócić do Bulwark.", + "page_failed": "Logowanie nie powiodło się. Wróć do Bulwark i spróbuj ponownie.", + "page_link_used": "Ten link działa tylko raz. Wróć do Bulwark i zacznij od nowa.", + "page_title": "Zaloguj się na {server}", + "page_intro": "Ta strona pochodzi z aplikacji Bulwark na tym komputerze. Twoje hasło trafia na {server} i nigdzie indziej.", + "page_username": "Adres e-mail lub nazwa użytkownika", + "page_password": "Hasło", + "page_code": "Kod dwuskładnikowy", + "page_submit": "Zaloguj się", + "page_wrong_password": "Serwer nie przyjął tej nazwy użytkownika i hasła.", + "page_code_needed": "To konto wymaga też kodu dwuskładnikowego. Wpisz ponownie hasło i kod.", + "page_no_answer": "Bulwark nie może się zalogować. Sprawdź, czy serwer jest dostępny, i spróbuj ponownie.", + "declined": "Odmówiono w przeglądarce, więc nic nie zostało zalogowane.", + "too_many": "Zbyt wiele prób logowania z tej sieci. Poczekaj kilka minut i spróbuj ponownie.", + "page_declined": "Nic nie zostało przekazane. Możesz zamknąć tę kartę." + }, "desktop_updates": { "title": "Aktualizacje", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Nie udało się skopiować pliku.", "reply_lost": "Nie udało się zachować wpisanej odpowiedzi. Otwórz wiadomość i odpowiedz w niej." }, + "desktopEdit": { + "edit": "Edytuj", + "edit_named": "Edytuj {name}", + "opened": "{name} jest otwarty w swoim programie. Zapisz tam zmiany, a zapytamy, co z nimi zrobić.", + "offers_label": "Edytowane załączniki", + "changed_title": "{name} został zmieniony", + "changed_body": "Zapisano zmiany w innym programie. Co z nimi zrobić?", + "reply": "Dołącz do odpowiedzi", + "new_message": "Dołącz do nowej wiadomości", + "open_draft": "Otwórz wersję roboczą", + "save_copy": "Zapisz kopię…", + "discard": "Odrzuć zmiany…", + "discard_title": "Odrzucić zmiany w {name}?", + "discard_detail": "Edytowana kopia zostanie usunięta. Oryginalny załącznik się nie zmieni.", + "discard_confirm": "Odrzuć", + "cancel": "Anuluj", + "update": "Zaktualizuj załącznik", + "no_app": "Żaden program na tym komputerze nie może otworzyć tego pliku.", + "blocked_type": "Tego typu pliku nie można otworzyć do edycji, ponieważ mógłby uruchomić się jako program.", + "blocked_policy": "Twoja organizacja nie zezwala na zapisywanie załączników na tym urządzeniu.", + "removed_policy": "Twoja organizacja już na to nie zezwala, więc edytowana kopia {name} została usunięta.", + "too_large": "Ten plik jest za duży, aby otworzyć go do edycji.", + "too_many": "Do edycji otwarto zbyt wiele plików. Najpierw zakończ pracę z niektórymi.", + "failed": "Nie udało się otworzyć pliku do edycji.", + "read_busy": "Inny program nadal używa {name}. Zamknij go tam i spróbuj ponownie.", + "read_failed": "Nie udało się odczytać edytowanej kopii {name}.", + "send_stale_title": "{name} ma zmiany, których nie ma w tej wiadomości", + "send_stale_detail": "Jeśli wyślesz teraz, wiadomość pójdzie ze starszą wersją. Zmiany zostaną na tym komputerze do 30 dni, dopóki nie zdecydujesz, co z nimi zrobić.", + "send_stale_send": "Wyślij mimo to", + "send_stale_back": "Wróć", + "signout_title": "Wylogować i usunąć zmiany?", + "signout_confirm": "Wyloguj", + "lock_note": "Pliki otwarte przez Edytuj pozostają otwarte w swoim programie, gdy aplikacja jest zablokowana, i można je nadal odczytać na tym komputerze. Nowych nie można otworzyć przed odblokowaniem.", + "changed_title_hidden": "Edytowany załącznik został zapisany", + "last_chance": "Nieużywany od 30 dni. Ta kopia zostanie jutro usunięta.", + "held_named": "{names}: nadal otwarte w innym programie, więc kopii nie można jeszcze usunąć. Zamknij tam, a zostanie usunięta.", + "held_unnamed": "Kopia załącznika jest nadal otwarta w innym programie i nie można jej jeszcze usunąć. Zamknij ją tam, a zostanie usunięta.", + "removed_policy_held": "Twoja organizacja już na to nie zezwala. {name} jest nadal otwarty w innym programie, więc kopię można usunąć dopiero po jego zamknięciu.", + "removed_policy_hidden": "Twoja organizacja nie zezwala już na edycję załączników, więc edytowana kopia została usunięta.", + "outside_note": "Plików odzyskiwania i listy ostatnich plików tego programu ta aplikacja nie może wyczyścić.", + "clear_title": "Usunąć edytowane załączniki?", + "clear_confirm": "Usuń", + "signout_changed": "Jeszcze niedołączone ani niezapisane, a teraz usunięte: {names}.", + "signout_held": "Nadal otwarte w innym programie, więc zostaną usunięte dopiero po zamknięciu: {names}." + }, "desktopQuickCompose": { "label": "Szybka wiadomość", "menu": "Szybka wiadomość", diff --git a/locales/pt/common.json b/locales/pt/common.json index c4b6b8348..2452edf4a 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Não ativado neste dispositivo", "status_unsupported": "Este navegador não oferece suporte a Web Push", "status_lite": "O Web Push não está disponível no Bulwark Lite; ele requer o servidor do Bulwark.", + "status_demo": "As notificações em segundo plano não estão disponíveis na demonstração.", "title": "Notificações em segundo plano", "devices_title": "Dispositivos registrados", "devices_desc": "Todos os dispositivos registrados para receber notificações em segundo plano desta conta. Revogar um deles interrompe o recebimento imediatamente.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "Este dispositivo deixará de receber notificações em segundo plano imediatamente.", "shared_mailboxes_no_push": "Caixas de correio que outras pessoas compartilham com você não enviam notificações em segundo plano.", "error_forbidden": "O servidor de e-mail não permite notificações em segundo plano para esta conta.", - "error_over_quota": "Esta conta tem registros push demais no servidor de e-mail (o limite costuma ser 15). Revogue os dispositivos que você não usa mais nas configurações de notificação e tente novamente." + "error_over_quota": "Esta conta tem registros push demais no servidor de e-mail (o limite costuma ser 15). Revogue os dispositivos que você não usa mais nas configurações de notificação e tente novamente.", + "error_blocked": "As notificações estão bloqueadas para este site. Permita-as nas definições do site do navegador e tente novamente.", + "error_not_granted": "As notificações não foram permitidas, por isso as notificações push continuam desligadas.", + "error_verification_timeout": "O servidor de correio não confirmou este dispositivo a tempo, por isso as notificações push continuam desligadas. Tente novamente dentro de um minuto.", + "error_relay": "Não é possível contactar o retransmissor de notificações neste momento, por isso as notificações push continuam desligadas. Tente mais tarde.", + "error_teardown": "As notificações push estão desligadas neste navegador, mas não foi possível contactar o servidor de correio ou o retransmissor. Podem continuar a chegar notificações durante alguns dias. Para as parar mais cedo, ligue as definições avançadas quando voltar a estar online e revogue este dispositivo em Dispositivos registados." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Demasiadas tentativas. Tente novamente daqui a {count, plural, one {# minuto} other {# minutos}}." }, "desktop_sign_in": { - "browser_button": "Entrar com o navegador", - "browser_waiting": "Termine de entrar no navegador", - "browser_unavailable": "Este servidor não oferece entrada pelo navegador. Use sua senha.", - "browser_failed": "Não foi possível entrar pelo navegador. Tente novamente.", "server_needed": "Digite primeiro o endereço do servidor.", "server_declined": "Você não permitiu este servidor, então nada foi enviado a ele.", "server_invalid": "Este endereço de servidor não é válido." }, + "desktop_browser_sign_in": { + "button": "Entrar com o navegador…", + "waiting": "A aguardar o navegador…", + "waiting_hint": "Termine de entrar por lá. O seu gestor de palavras-passe funciona no navegador.", + "cancel": "Cancelar", + "or_here": "ou entre aqui", + "timed_out": "A entrada no navegador demorou demasiado. Tente novamente.", + "no_browser": "O Bulwark não conseguiu abrir o navegador. Tente novamente ou entre aqui.", + "failed": "Não foi possível entrar através do navegador. Tente novamente.", + "handoff_banner": "Um programa neste computador pediu esta sessão para a aplicação Bulwark. Este navegador continua sem sessão no Bulwark. Se não foi você que iniciou isto, feche este separador.", + "consent_title": "Entrar na aplicação Bulwark?", + "consent_description": "Um programa neste computador, na porta {port}, está a pedir a sessão de {username}. Continue só se acabou de carregar em «Entrar com o navegador…» na aplicação Bulwark aqui. Caso contrário, alguém pode estar a tentar entrar na sua conta: escolha Agora não.", + "consent_confirm": "Entrar na aplicação", + "consent_cancel": "Agora não", + "page_done": "Pode fechar este separador e voltar ao Bulwark.", + "page_failed": "Não foi possível entrar. Volte ao Bulwark e tente novamente.", + "page_link_used": "Esta ligação só funciona uma vez. Volte ao Bulwark e comece de novo.", + "page_title": "Entrar em {server}", + "page_intro": "Esta página vem da aplicação Bulwark neste computador. A sua palavra-passe vai para {server} e para mais lado nenhum.", + "page_username": "Endereço de e-mail ou nome de utilizador", + "page_password": "Palavra-passe", + "page_code": "Código de dois fatores", + "page_submit": "Entrar", + "page_wrong_password": "O servidor não aceitou este nome de utilizador e esta palavra-passe.", + "page_code_needed": "Esta conta também precisa de um código de dois fatores. Introduza novamente a palavra-passe e o código.", + "page_no_answer": "O Bulwark não conseguiu entrar. Verifique se o servidor está acessível e tente novamente.", + "declined": "Disse que não no navegador, por isso não foi iniciada nenhuma sessão.", + "too_many": "Demasiadas tentativas de entrada a partir desta rede. Aguarde alguns minutos e tente novamente.", + "page_declined": "Nada foi entregue. Pode fechar este separador." + }, "desktop_updates": { "title": "Atualizações", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Não foi possível copiar o ficheiro.", "reply_lost": "Não foi possível guardar a resposta escrita. Abra a mensagem e responda a partir dela." }, + "desktopEdit": { + "edit": "Editar", + "edit_named": "Editar {name}", + "opened": "{name} está aberto no respetivo programa. Guarde lá as alterações e ser-lhe-á perguntado o que fazer com elas.", + "offers_label": "Anexos editados", + "changed_title": "{name} foi alterado", + "changed_body": "Guardou alterações noutro programa. O que pretende fazer com elas?", + "reply": "Anexar a uma resposta", + "new_message": "Anexar a uma nova mensagem", + "open_draft": "Abrir rascunho", + "save_copy": "Guardar uma cópia…", + "discard": "Rejeitar alterações…", + "discard_title": "Rejeitar as alterações a {name}?", + "discard_detail": "A cópia editada é eliminada. O anexo original não muda.", + "discard_confirm": "Rejeitar", + "cancel": "Cancelar", + "update": "Atualizar anexo", + "no_app": "Nenhum programa deste computador consegue abrir este ficheiro.", + "blocked_type": "Este tipo de ficheiro não pode ser aberto para edição, porque pode ser executado como programa.", + "blocked_policy": "A sua organização não permite guardar anexos neste dispositivo.", + "removed_policy": "A sua organização já não o permite, por isso a cópia editada de {name} foi eliminada.", + "too_large": "Este ficheiro é demasiado grande para ser aberto para edição.", + "too_many": "Há demasiados ficheiros abertos para edição. Termine primeiro alguns deles.", + "failed": "Não foi possível abrir o ficheiro para edição.", + "read_busy": "Outro programa ainda está a usar {name}. Feche-o lá e tente novamente.", + "read_failed": "Não foi possível ler a cópia editada de {name}.", + "send_stale_title": "{name} tem alterações que não estão nesta mensagem", + "send_stale_detail": "Se enviar agora, a mensagem segue com a versão anterior. As suas alterações ficam neste computador até 30 dias, até decidir o que fazer com elas.", + "send_stale_send": "Enviar na mesma", + "send_stale_back": "Voltar", + "signout_title": "Terminar sessão e eliminar as alterações?", + "signout_confirm": "Terminar sessão", + "lock_note": "Os ficheiros abertos com Editar continuam abertos no respetivo programa enquanto a aplicação está bloqueada, e continuam legíveis neste computador. Não é possível abrir outros até desbloquear.", + "changed_title_hidden": "Um anexo editado foi guardado", + "last_chance": "Sem uso há 30 dias. Esta cópia é eliminada amanhã.", + "held_named": "{names}: ainda aberto noutro programa, por isso a cópia ainda não pode ser eliminada. Feche-o lá e será eliminada.", + "held_unnamed": "A cópia de um anexo ainda está aberta noutro programa e ainda não pode ser eliminada. Feche-a lá e será eliminada.", + "removed_policy_held": "A sua organização já não o permite. {name} ainda está aberto noutro programa, por isso a cópia só pode ser eliminada depois de o fechar lá.", + "removed_policy_hidden": "A sua organização já não permite editar anexos, por isso uma cópia editada foi removida.", + "outside_note": "Os ficheiros de recuperação e a lista de ficheiros recentes desse programa estão fora do alcance desta aplicação.", + "clear_title": "Eliminar os anexos editados?", + "clear_confirm": "Eliminar", + "signout_changed": "Ainda não anexados nem guardados, e eliminados agora: {names}.", + "signout_held": "Ainda abertos noutro programa, por isso só são eliminados depois de os fechar lá: {names}." + }, "desktopQuickCompose": { "label": "Escrita rápida", "menu": "Escrita rápida", diff --git a/locales/ro/common.json b/locales/ro/common.json index 719df8c78..03bfef66a 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "Nu este activat pe acest dispozitiv", "status_unsupported": "Acest browser nu acceptă Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Notificările în fundal nu sunt disponibile în demonstrație.", "status_busy": "Se lucrează…", "enable": "Activați", "reenable": "Reînregistrează-te", @@ -1766,7 +1767,12 @@ "confirm_revoke_message_this": "Acest dispozitiv va înceta imediat să primească notificări în fundal.", "shared_mailboxes_no_push": "Căsuțele poștale pe care alții le partajează cu dvs. nu trimit notificări în fundal.", "error_forbidden": "Serverul de e-mail nu permite notificări în fundal pentru acest cont.", - "error_over_quota": "Acest cont are prea multe înregistrări push pe serverul de e-mail (limita este de obicei 15). Revocați din setările de notificări dispozitivele pe care nu le mai folosiți, apoi încercați din nou." + "error_over_quota": "Acest cont are prea multe înregistrări push pe serverul de e-mail (limita este de obicei 15). Revocați din setările de notificări dispozitivele pe care nu le mai folosiți, apoi încercați din nou.", + "error_blocked": "Notificările sunt blocate pentru acest site. Permite-le în setările site-ului din browser, apoi încearcă din nou.", + "error_not_granted": "Notificările nu au fost permise, deci push rămâne dezactivat.", + "error_verification_timeout": "Serverul de e-mail nu a confirmat la timp acest dispozitiv, deci push rămâne dezactivat. Încearcă din nou peste un minut.", + "error_relay": "Releul push nu poate fi accesat acum, deci push rămâne dezactivat. Încearcă din nou mai târziu.", + "error_teardown": "Push este dezactivat în acest browser, dar serverul de e-mail sau releul nu au putut fi accesate. Notificările pot continua să sosească câteva zile. Pentru a le opri mai devreme, când ești din nou online activează setările avansate și revocă acest dispozitiv la Dispozitive înregistrate." }, "push_all": { "title": "All accounts", @@ -6873,14 +6879,40 @@ "blocked_minutes": "Prea multe încercări. Încercați din nou peste {count, plural, one {# minut} few {# minute} other {# de minute}}." }, "desktop_sign_in": { - "browser_button": "Conectează-te cu browserul", - "browser_waiting": "Finalizează conectarea în browser", - "browser_unavailable": "Acest server nu oferă conectare prin browser. Folosește parola.", - "browser_failed": "Conectarea prin browser nu a reușit. Încearcă din nou.", "server_needed": "Introdu mai întâi adresa serverului.", "server_declined": "Nu ai permis acest server, așa că nu i s-a trimis nimic.", "server_invalid": "Această adresă de server nu este validă." }, + "desktop_browser_sign_in": { + "button": "Conectați-vă cu browserul…", + "waiting": "Se așteaptă browserul…", + "waiting_hint": "Terminați conectarea acolo. Managerul de parole funcționează în browser.", + "cancel": "Anulați", + "or_here": "sau conectați-vă aici", + "timed_out": "Conectarea în browser a durat prea mult. Încercați din nou.", + "no_browser": "Bulwark nu a putut deschide browserul. Încercați din nou sau conectați-vă aici.", + "failed": "Conectarea prin browser nu a reușit. Încercați din nou.", + "handoff_banner": "Un program de pe acest computer a cerut această conectare pentru aplicația Bulwark. Acest browser rămâne deconectat de la Bulwark. Dacă nu ați pornit dvs. acest lucru, închideți această filă.", + "consent_title": "Vă conectați la aplicația Bulwark?", + "consent_description": "Un program de pe acest computer, la portul {port}, cere conectarea pentru {username}. Continuați doar dacă tocmai ați apăsat „Conectați-vă cu browserul…” în aplicația Bulwark de aici. Dacă nu, poate cineva încearcă să intre în contul dvs.: alegeți Nu acum.", + "consent_confirm": "Conectați-vă la aplicație", + "consent_cancel": "Nu acum", + "page_done": "Puteți închide această filă și reveni la Bulwark.", + "page_failed": "Conectarea nu a reușit. Reveniți la Bulwark și încercați din nou.", + "page_link_used": "Acest link funcționează o singură dată. Reveniți la Bulwark și începeți din nou.", + "page_title": "Conectați-vă la {server}", + "page_intro": "Această pagină vine de la aplicația Bulwark de pe acest computer. Parola merge la {server} și nicăieri altundeva.", + "page_username": "Adresă de e-mail sau nume de utilizator", + "page_password": "Parolă", + "page_code": "Cod cu doi factori", + "page_submit": "Conectați-vă", + "page_wrong_password": "Serverul nu a acceptat acest nume de utilizator și această parolă.", + "page_code_needed": "Acest cont are nevoie și de un cod cu doi factori. Introduceți din nou parola și codul.", + "page_no_answer": "Bulwark nu s-a putut conecta. Verificați dacă serverul este accesibil și încercați din nou.", + "declined": "Ați refuzat în browser, așa că nu s-a făcut nicio conectare.", + "too_many": "Prea multe încercări de conectare din această rețea. Așteptați câteva minute și încercați din nou.", + "page_declined": "Nu s-a transmis nimic. Puteți închide această filă." + }, "desktop_updates": { "title": "Actualizări", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Fișierul nu a putut fi copiat.", "reply_lost": "Răspunsul scris nu a putut fi păstrat. Deschide mesajul și răspunde de acolo." }, + "desktopEdit": { + "edit": "Editează", + "edit_named": "Editează {name}", + "opened": "{name} este deschis în programul său. Salvați modificările acolo și veți fi întrebat ce să faceți cu ele.", + "offers_label": "Atașamente editate", + "changed_title": "{name} a fost modificat", + "changed_body": "Ați salvat modificări în alt program. Ce să se întâmple cu ele?", + "reply": "Atașează la un răspuns", + "new_message": "Atașează la un mesaj nou", + "open_draft": "Deschide ciorna", + "save_copy": "Salvează o copie…", + "discard": "Renunță la modificări…", + "discard_title": "Renunțați la modificările din {name}?", + "discard_detail": "Copia editată este ștearsă. Atașamentul original nu se schimbă.", + "discard_confirm": "Renunță", + "cancel": "Anulează", + "update": "Actualizează atașamentul", + "no_app": "Niciun program de pe acest computer nu poate deschide acest fișier.", + "blocked_type": "Acest tip de fișier nu poate fi deschis pentru editare, deoarece ar putea rula ca program.", + "blocked_policy": "Organizația dvs. nu permite salvarea atașamentelor pe acest dispozitiv.", + "removed_policy": "Organizația dvs. nu mai permite acest lucru, așa că a fost ștearsă copia editată a {name}.", + "too_large": "Acest fișier este prea mare pentru a fi deschis pentru editare.", + "too_many": "Sunt prea multe fișiere deschise pentru editare. Terminați mai întâi cu câteva.", + "failed": "Fișierul nu a putut fi deschis pentru editare.", + "read_busy": "Alt program încă folosește {name}. Închideți-l acolo și încercați din nou.", + "read_failed": "Copia editată a {name} nu a putut fi citită.", + "send_stale_title": "{name} are modificări care nu sunt în acest mesaj", + "send_stale_detail": "Dacă trimiteți acum, mesajul pleacă cu versiunea mai veche. Modificările rămân pe acest computer până la 30 de zile, până când decideți ce faceți cu ele.", + "send_stale_send": "Trimite oricum", + "send_stale_back": "Înapoi", + "signout_title": "Vă deconectați și ștergeți modificările?", + "signout_confirm": "Deconectare", + "lock_note": "Fișierele deschise cu Editează rămân deschise în programul lor cât timp aplicația este blocată și rămân lizibile pe acest computer. Altele noi nu se pot deschide până la deblocare.", + "changed_title_hidden": "Un atașament editat a fost salvat", + "last_chance": "Nefolosit de 30 de zile. Această copie se șterge mâine.", + "held_named": "{names}: încă deschis în alt program, așa că copia nu poate fi ștearsă încă. Închideți-l acolo și va fi ștearsă.", + "held_unnamed": "Copia unui atașament este încă deschisă în alt program și nu poate fi ștearsă încă. Închideți-o acolo și va fi ștearsă.", + "removed_policy_held": "Organizația dvs. nu mai permite acest lucru. {name} este încă deschis în alt program, așa că copia poate fi ștearsă doar după ce îl închideți acolo.", + "removed_policy_hidden": "Organizația dvs. nu mai permite editarea atașamentelor, așa că o copie editată a fost eliminată.", + "outside_note": "Fișierele de recuperare și lista de fișiere recente ale acelui program nu pot fi șterse de această aplicație.", + "clear_title": "Ștergeți atașamentele editate?", + "clear_confirm": "Șterge", + "signout_changed": "Încă neatașate sau nesalvate, și șterse acum: {names}.", + "signout_held": "Încă deschise în alt program, așa că se șterg doar după ce le închideți acolo: {names}." + }, "desktopQuickCompose": { "label": "Scriere rapidă", "menu": "Scriere rapidă", diff --git a/locales/ru/common.json b/locales/ru/common.json index 066ad844b..ebb25a96f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Не включено на этом устройстве", "status_unsupported": "Этот браузер не поддерживает Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Фоновые уведомления недоступны в демоверсии.", "title": "Фоновые уведомления", "devices_title": "Зарегистрированные устройства", "devices_desc": "Все устройства, зарегистрированные для получения фоновых уведомлений этой учётной записи. После отзыва устройство сразу перестаёт их получать.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "Это устройство сразу перестанет получать фоновые уведомления.", "shared_mailboxes_no_push": "Почтовые ящики, к которым вам открыли доступ другие пользователи, не присылают фоновые уведомления.", "error_forbidden": "Почтовый сервер не разрешает фоновые уведомления для этой учётной записи.", - "error_over_quota": "У этой учётной записи слишком много push-регистраций на почтовом сервере (обычно разрешено 15). Отзовите неиспользуемые устройства в настройках уведомлений и повторите попытку." + "error_over_quota": "У этой учётной записи слишком много push-регистраций на почтовом сервере (обычно разрешено 15). Отзовите неиспользуемые устройства в настройках уведомлений и повторите попытку.", + "error_blocked": "Уведомления для этого сайта заблокированы. Разрешите их в настройках сайта в браузере и попробуйте снова.", + "error_not_granted": "Уведомления не разрешены, поэтому push остаётся выключенным.", + "error_verification_timeout": "Почтовый сервер не подтвердил это устройство вовремя, поэтому push остаётся выключенным. Попробуйте снова через минуту.", + "error_relay": "Push-ретранслятор сейчас недоступен, поэтому push остаётся выключенным. Попробуйте позже.", + "error_teardown": "Push в этом браузере выключен, но почтовый сервер или ретранслятор были недоступны. Уведомления могут приходить ещё несколько дней. Чтобы остановить их раньше, когда снова будете в сети, включите расширенные настройки и отзовите это устройство в разделе «Зарегистрированные устройства»." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Слишком много попыток. Повторите через {count, plural, one {# минуту} few {# минуты} many {# минут} other {# минуты}}." }, "desktop_sign_in": { - "browser_button": "Войти через браузер", - "browser_waiting": "Завершите вход в браузере", - "browser_unavailable": "Этот сервер не поддерживает вход через браузер. Используйте пароль.", - "browser_failed": "Не удалось войти через браузер. Попробуйте ещё раз.", "server_needed": "Сначала введите адрес сервера.", "server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.", "server_invalid": "Этот адрес сервера недействителен." }, + "desktop_browser_sign_in": { + "button": "Войти через браузер…", + "waiting": "Ожидание браузера…", + "waiting_hint": "Завершите вход там. Ваш менеджер паролей работает в браузере.", + "cancel": "Отмена", + "or_here": "или войдите здесь", + "timed_out": "Вход через браузер занял слишком много времени. Попробуйте ещё раз.", + "no_browser": "Bulwark не смог открыть браузер. Попробуйте ещё раз или войдите здесь.", + "failed": "Не удалось войти через браузер. Попробуйте ещё раз.", + "handoff_banner": "Программа на этом компьютере запросила этот вход для приложения Bulwark. В этом браузере вход в Bulwark не выполняется. Если это начали не вы, закройте вкладку.", + "consent_title": "Войти в приложение Bulwark?", + "consent_description": "Программа на этом компьютере, на порту {port}, запрашивает вход для {username}. Продолжайте, только если вы только что нажали «Войти через браузер…» в приложении Bulwark на этом компьютере. Если нет, возможно, кто-то пытается попасть в вашу учётную запись: выберите «Не сейчас».", + "consent_confirm": "Войти в приложение", + "consent_cancel": "Не сейчас", + "page_done": "Можно закрыть эту вкладку и вернуться в Bulwark.", + "page_failed": "Не удалось войти. Вернитесь в Bulwark и попробуйте ещё раз.", + "page_link_used": "Эта ссылка работает только один раз. Вернитесь в Bulwark и начните заново.", + "page_title": "Вход на {server}", + "page_intro": "Эта страница открыта приложением Bulwark на этом компьютере. Ваш пароль уходит на {server} и больше никуда.", + "page_username": "Адрес эл. почты или имя пользователя", + "page_password": "Пароль", + "page_code": "Двухфакторный код", + "page_submit": "Войти", + "page_wrong_password": "Сервер не принял это имя пользователя и пароль.", + "page_code_needed": "Для этой учётной записи также нужен двухфакторный код. Введите пароль ещё раз и код.", + "page_no_answer": "Bulwark не смог войти. Проверьте, доступен ли сервер, и попробуйте ещё раз.", + "declined": "Вы отказали в браузере, поэтому вход не выполнен.", + "too_many": "Слишком много попыток входа из этой сети. Подождите несколько минут и попробуйте ещё раз.", + "page_declined": "Ничего не передано. Можно закрыть эту вкладку." + }, "desktop_updates": { "title": "Обновления", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Не удалось скопировать файл.", "reply_lost": "Не удалось сохранить введённый ответ. Откройте письмо и ответьте из него." }, + "desktopEdit": { + "edit": "Изменить", + "edit_named": "Изменить {name}", + "opened": "{name} открыт в своей программе. Сохраните там изменения, и вас спросят, что с ними сделать.", + "offers_label": "Изменённые вложения", + "changed_title": "{name} изменён", + "changed_body": "Вы сохранили изменения в другой программе. Что с ними сделать?", + "reply": "Вложить в ответ", + "new_message": "Вложить в новое письмо", + "open_draft": "Открыть черновик", + "save_copy": "Сохранить копию…", + "discard": "Отменить изменения…", + "discard_title": "Отменить изменения в {name}?", + "discard_detail": "Изменённая копия будет удалена. Исходное вложение не изменится.", + "discard_confirm": "Отменить изменения", + "cancel": "Отмена", + "update": "Обновить вложение", + "no_app": "Ни одна программа на этом компьютере не может открыть этот файл.", + "blocked_type": "Файл такого типа нельзя открыть для изменения: он может запуститься как программа.", + "blocked_policy": "Ваша организация не разрешает сохранять вложения на этом устройстве.", + "removed_policy": "Ваша организация больше этого не разрешает, поэтому изменённая копия {name} удалена.", + "too_large": "Этот файл слишком большой, чтобы открыть его для изменения.", + "too_many": "Для изменения открыто слишком много файлов. Сначала закончите с некоторыми из них.", + "failed": "Не удалось открыть файл для изменения.", + "read_busy": "Другая программа ещё использует {name}. Закройте его там и повторите попытку.", + "read_failed": "Не удалось прочитать изменённую копию {name}.", + "send_stale_title": "В {name} есть изменения, которых нет в этом письме", + "send_stale_detail": "Если отправить сейчас, письмо уйдёт со старой версией. Ваши изменения останутся на этом компьютере до 30 дней, пока вы не решите, что с ними делать.", + "send_stale_send": "Всё равно отправить", + "send_stale_back": "Назад", + "signout_title": "Выйти и удалить изменения?", + "signout_confirm": "Выйти", + "lock_note": "Файлы, открытые через «Изменить», остаются открытыми в своей программе, пока приложение заблокировано, и их по-прежнему можно прочитать на этом компьютере. Новые нельзя открыть, пока вы не разблокируете приложение.", + "changed_title_hidden": "Изменённое вложение сохранено", + "last_chance": "Не использовалось 30 дней. Эта копия будет удалена завтра.", + "held_named": "{names}: ещё открыто в другой программе, поэтому копию пока нельзя удалить. Закройте там, и она будет удалена.", + "held_unnamed": "Копия вложения ещё открыта в другой программе, и её пока нельзя удалить. Закройте её там, и она будет удалена.", + "removed_policy_held": "Ваша организация больше этого не разрешает. {name} ещё открыт в другой программе, поэтому копию можно удалить только после его закрытия там.", + "removed_policy_hidden": "Ваша организация больше не разрешает изменять вложения, поэтому изменённая копия удалена.", + "outside_note": "Файлы восстановления и список недавних файлов той программы это приложение очистить не может.", + "clear_title": "Удалить изменённые вложения?", + "clear_confirm": "Удалить", + "signout_changed": "Ещё не вложены и не сохранены, и сейчас удаляются: {names}.", + "signout_held": "Ещё открыты в другой программе, поэтому удалятся только после закрытия там: {names}." + }, "desktopQuickCompose": { "label": "Быстрое письмо", "menu": "Быстрое письмо", diff --git a/locales/sk/common.json b/locales/sk/common.json index f9f7e864a..43343c99e 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1776,6 +1776,7 @@ "status_inactive": "Nie je povolené na tomto zariadení", "status_unsupported": "Tento prehliadač nepodporuje Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Oznámenia na pozadí nie sú v ukážke k dispozícii.", "status_busy": "Pracujem…", "enable": "Povoliť", "reenable": "Znovu zaregistrovať", @@ -1802,7 +1803,12 @@ "confirm_revoke_message_this": "Toto zariadenie okamžite prestane prijímať oznámenia na pozadí.", "shared_mailboxes_no_push": "Poštové schránky, ktoré s vami zdieľajú iní, neposielajú oznámenia na pozadí.", "error_forbidden": "Poštový server pre tento účet nepovoľuje oznámenia na pozadí.", - "error_over_quota": "Tento účet má na poštovom serveri príliš veľa registrácií push (limit je zvyčajne 15). V nastaveniach oznámení odvolajte zariadenia, ktoré už nepoužívate, a skúste to znova." + "error_over_quota": "Tento účet má na poštovom serveri príliš veľa registrácií push (limit je zvyčajne 15). V nastaveniach oznámení odvolajte zariadenia, ktoré už nepoužívate, a skúste to znova.", + "error_blocked": "Oznámenia sú pre túto stránku zablokované. Povoľte ich v nastaveniach stránky v prehliadači a skúste to znova.", + "error_not_granted": "Oznámenia neboli povolené, takže push zostáva vypnutý.", + "error_verification_timeout": "Poštový server toto zariadenie včas nepotvrdil, takže push zostáva vypnutý. Skúste to znova o minútu.", + "error_relay": "Push relay teraz nie je dostupné, takže push zostáva vypnutý. Skúste to neskôr.", + "error_teardown": "Push je v tomto prehliadači vypnutý, ale poštový server alebo relay neboli dostupné. Oznámenia môžu ešte niekoľko dní prichádzať. Ak ich chcete zastaviť skôr, keď budete online, zapnite rozšírené nastavenia a odvolajte toto zariadenie v časti Registrované zariadenia." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Príliš veľa pokusov. Skúste to o {count, plural, one {# minútu} few {# minúty} many {# minúty} other {# minút}}." }, "desktop_sign_in": { - "browser_button": "Prihlásiť sa v prehliadači", - "browser_waiting": "Dokončite prihlásenie v prehliadači", - "browser_unavailable": "Tento server neponúka prihlásenie cez prehliadač. Použite heslo.", - "browser_failed": "Prihlásenie cez prehliadač sa nepodarilo. Skúste to znova.", "server_needed": "Najprv zadajte adresu servera.", "server_declined": "Tento server ste nepovolili, preto sa naň nič neodoslalo.", "server_invalid": "Táto adresa servera nie je platná." }, + "desktop_browser_sign_in": { + "button": "Prihlásiť sa v prehliadači…", + "waiting": "Čaká sa na prehliadač…", + "waiting_hint": "Dokončite prihlásenie tam. Váš správca hesiel funguje v prehliadači.", + "cancel": "Zrušiť", + "or_here": "alebo sa prihláste tu", + "timed_out": "Prihlásenie v prehliadači trvalo príliš dlho. Skúste to znova.", + "no_browser": "Bulwark nemohol otvoriť prehliadač. Skúste to znova alebo sa prihláste tu.", + "failed": "Prihlásenie cez prehliadač sa nepodarilo. Skúste to znova.", + "handoff_banner": "Program v tomto počítači požiadal o toto prihlásenie pre aplikáciu Bulwark. Tento prehliadač zostane z Bulwarku odhlásený. Ak ste to nespustili vy, zavrite túto kartu.", + "consent_title": "Prihlásiť sa do aplikácie Bulwark?", + "consent_description": "Program v tomto počítači, na porte {port}, žiada o prihlásenie pre {username}. Pokračujte, len ak ste práve klikli na „Prihlásiť sa v prehliadači…“ v aplikácii Bulwark v tomto počítači. Ak nie, možno sa niekto snaží dostať do vášho účtu: zvoľte Teraz nie.", + "consent_confirm": "Prihlásiť sa do aplikácie", + "consent_cancel": "Teraz nie", + "page_done": "Túto kartu môžete zavrieť a vrátiť sa do Bulwarku.", + "page_failed": "Prihlásenie sa nepodarilo. Vráťte sa do Bulwarku a skúste to znova.", + "page_link_used": "Tento odkaz funguje len raz. Vráťte sa do Bulwarku a začnite znova.", + "page_title": "Prihlásiť sa na {server}", + "page_intro": "Táto stránka pochádza z aplikácie Bulwark v tomto počítači. Vaše heslo ide na {server} a nikam inam.", + "page_username": "E-mailová adresa alebo používateľské meno", + "page_password": "Heslo", + "page_code": "Dvojfaktorový kód", + "page_submit": "Prihlásiť sa", + "page_wrong_password": "Server toto používateľské meno a heslo neprijal.", + "page_code_needed": "Tento účet potrebuje aj dvojfaktorový kód. Zadajte znova heslo a kód.", + "page_no_answer": "Bulwark sa nemohol prihlásiť. Skontrolujte, či je server dostupný, a skúste to znova.", + "declined": "V prehliadači ste odmietli, takže k prihláseniu nedošlo.", + "too_many": "Príliš veľa pokusov o prihlásenie z tejto siete. Počkajte niekoľko minút a skúste to znova.", + "page_declined": "Nič nebolo odovzdané. Túto kartu môžete zavrieť." + }, "desktop_updates": { "title": "Aktualizácie", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Súbor sa nepodarilo skopírovať.", "reply_lost": "Napísanú odpoveď sa nepodarilo zachovať. Otvorte správu a odpovedzte v nej." }, + "desktopEdit": { + "edit": "Upraviť", + "edit_named": "Upraviť {name}", + "opened": "{name} je otvorený vo svojom programe. Uložte tam zmeny a potom sa vás spýtame, čo s nimi urobiť.", + "offers_label": "Upravené prílohy", + "changed_title": "{name} sa zmenil", + "changed_body": "Uložili ste zmeny v inom programe. Čo s nimi chcete urobiť?", + "reply": "Pripojiť k odpovedi", + "new_message": "Pripojiť k novej správe", + "open_draft": "Otvoriť koncept", + "save_copy": "Uložiť kópiu…", + "discard": "Zahodiť zmeny…", + "discard_title": "Zahodiť zmeny v {name}?", + "discard_detail": "Upravená kópia sa odstráni. Pôvodná príloha sa nezmení.", + "discard_confirm": "Zahodiť", + "cancel": "Zrušiť", + "update": "Aktualizovať prílohu", + "no_app": "Tento súbor nedokáže otvoriť žiadny program v tomto počítači.", + "blocked_type": "Tento typ súboru nemožno otvoriť na úpravy, pretože by sa mohol spustiť ako program.", + "blocked_policy": "Vaša organizácia nepovoľuje ukladať prílohy do tohto zariadenia.", + "removed_policy": "Vaša organizácia to už nepovoľuje, preto bola upravená kópia {name} odstránená.", + "too_large": "Tento súbor je príliš veľký na otvorenie na úpravy.", + "too_many": "Na úpravy je otvorených príliš veľa súborov. Niektoré najprv dokončite.", + "failed": "Súbor sa nepodarilo otvoriť na úpravy.", + "read_busy": "{name} stále používa iný program. Zatvorte ho tam a skúste to znova.", + "read_failed": "Upravenú kópiu {name} sa nepodarilo prečítať.", + "send_stale_title": "{name} má zmeny, ktoré v tejto správe nie sú", + "send_stale_detail": "Ak odošlete teraz, správa odíde so staršou verziou. Vaše zmeny zostanú v tomto počítači až 30 dní, kým sa nerozhodnete, čo s nimi.", + "send_stale_send": "Napriek tomu odoslať", + "send_stale_back": "Späť", + "signout_title": "Odhlásiť sa a odstrániť zmeny?", + "signout_confirm": "Odhlásiť sa", + "lock_note": "Súbory otvorené cez Upraviť zostávajú otvorené vo svojom programe, kým je aplikácia zamknutá, a zostávajú v tomto počítači čitateľné. Nové sa otvoria až po odomknutí.", + "changed_title_hidden": "Upravená príloha bola uložená", + "last_chance": "Nepoužité 30 dní. Táto kópia sa zajtra odstráni.", + "held_named": "{names}: stále otvorené v inom programe, takže kópiu zatiaľ nemožno odstrániť. Zatvorte to tam a odstráni sa.", + "held_unnamed": "Kópia prílohy je stále otvorená v inom programe a zatiaľ ju nemožno odstrániť. Zatvorte ju tam a odstráni sa.", + "removed_policy_held": "Vaša organizácia to už nepovoľuje. {name} je stále otvorený v inom programe, takže kópiu možno odstrániť až po jeho zatvorení.", + "removed_policy_hidden": "Vaša organizácia už nepovoľuje upravovať prílohy, preto bola upravená kópia odstránená.", + "outside_note": "Súbory na obnovenie a zoznam nedávnych súborov toho programu táto aplikácia nemôže vymazať.", + "clear_title": "Odstrániť upravené prílohy?", + "clear_confirm": "Odstrániť", + "signout_changed": "Zatiaľ nepripojené ani neuložené, a teraz odstránené: {names}.", + "signout_held": "Stále otvorené v inom programe, takže sa odstránia až po zatvorení: {names}." + }, "desktopQuickCompose": { "label": "Rýchla správa", "menu": "Rýchla správa", diff --git a/locales/th/common.json b/locales/th/common.json index 7ac4b5423..d4aef2af2 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -4636,6 +4636,7 @@ "status_inactive": "ไม่ได้เปิดใช้งานบนอุปกรณ์นี้", "status_unsupported": "เบราว์เซอร์นี้ไม่รองรับ Web Push", "status_lite": "Web Push ไม่พร้อมใช้งานใน Bulwark Lite จำเป็นต้องใช้ Bulwark server", + "status_demo": "การแจ้งเตือนในพื้นหลังไม่พร้อมใช้งานในเดโม", "status_busy": "กำลังดำเนินการ…", "enable": "เปิดใช้งาน", "reenable": "ลงทะเบียนใหม่", @@ -4662,7 +4663,12 @@ "confirm_revoke_message_this": "อุปกรณ์นี้จะหยุดรับการแจ้งเตือนเบื้องหลังทันที", "shared_mailboxes_no_push": "กล่องข้อความที่ผู้อื่นแชร์กับคุณจะไม่ส่งการแจ้งเตือนเบื้องหลัง", "error_forbidden": "เมลเซิร์ฟเวอร์ไม่อนุญาตการแจ้งเตือนเบื้องหลังสำหรับบัญชีนี้", - "error_over_quota": "บัญชีนี้มีการลงทะเบียนพุชบนเมลเซิร์ฟเวอร์มากเกินไป (ขีดจำกัดปกติคือ 15 อุปกรณ์) กรุณาเพิกถอนอุปกรณ์ที่ไม่ได้ใช้งานแล้วในการตั้งค่าการแจ้งเตือน แล้วลองใหม่อีกครั้ง" + "error_over_quota": "บัญชีนี้มีการลงทะเบียนพุชบนเมลเซิร์ฟเวอร์มากเกินไป (ขีดจำกัดปกติคือ 15 อุปกรณ์) กรุณาเพิกถอนอุปกรณ์ที่ไม่ได้ใช้งานแล้วในการตั้งค่าการแจ้งเตือน แล้วลองใหม่อีกครั้ง", + "error_blocked": "การแจ้งเตือนถูกบล็อกสำหรับเว็บไซต์นี้ อนุญาตในการตั้งค่าเว็บไซต์ของเบราว์เซอร์ แล้วลองอีกครั้ง", + "error_not_granted": "ไม่ได้อนุญาตการแจ้งเตือน พุชจึงยังปิดอยู่", + "error_verification_timeout": "เซิร์ฟเวอร์อีเมลไม่ยืนยันอุปกรณ์นี้ทันเวลา พุชจึงยังปิดอยู่ ลองอีกครั้งในอีกหนึ่งนาที", + "error_relay": "ไม่สามารถเชื่อมต่อรีเลย์พุชได้ในขณะนี้ พุชจึงยังปิดอยู่ ลองอีกครั้งภายหลัง", + "error_teardown": "ปิดพุชในเบราว์เซอร์นี้แล้ว แต่ไม่สามารถเชื่อมต่อเซิร์ฟเวอร์อีเมลหรือรีเลย์ได้ อาจยังได้รับการแจ้งเตือนอีกสองสามวัน หากต้องการหยุดเร็วขึ้น เมื่อกลับมาออนไลน์ให้เปิดการตั้งค่าขั้นสูงและเพิกถอนอุปกรณ์นี้ในอุปกรณ์ที่ลงทะเบียน" }, "push_all": { "title": "ทุกบัญชี", @@ -6881,14 +6887,40 @@ "blocked_minutes": "ลองหลายครั้งเกินไป ลองอีกครั้งใน {count, plural, other {# นาที}}" }, "desktop_sign_in": { - "browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์", - "browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์", - "browser_unavailable": "เซิร์ฟเวอร์นี้ไม่รองรับการลงชื่อเข้าใช้ผ่านเบราว์เซอร์ โปรดใช้รหัสผ่าน", - "browser_failed": "ลงชื่อเข้าใช้ผ่านเบราว์เซอร์ไม่สำเร็จ โปรดลองอีกครั้ง", "server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน", "server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป", "server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง" }, + "desktop_browser_sign_in": { + "button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์…", + "waiting": "กำลังรอเบราว์เซอร์…", + "waiting_hint": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์ ตัวจัดการรหัสผ่านของคุณใช้งานได้ที่นั่น", + "cancel": "ยกเลิก", + "or_here": "หรือลงชื่อเข้าใช้ที่นี่", + "timed_out": "การลงชื่อเข้าใช้ในเบราว์เซอร์ใช้เวลานานเกินไป ลองอีกครั้ง", + "no_browser": "Bulwark เปิดเบราว์เซอร์ไม่ได้ ลองอีกครั้งหรือลงชื่อเข้าใช้ที่นี่", + "failed": "ลงชื่อเข้าใช้ผ่านเบราว์เซอร์ไม่สำเร็จ ลองอีกครั้ง", + "handoff_banner": "โปรแกรมบนคอมพิวเตอร์เครื่องนี้ขอการลงชื่อเข้าใช้นี้สำหรับแอป Bulwark เบราว์เซอร์นี้จะยังไม่ได้ลงชื่อเข้าใช้ Bulwark หากคุณไม่ได้เริ่มเอง ให้ปิดแท็บนี้", + "consent_title": "ลงชื่อเข้าใช้แอป Bulwark หรือไม่", + "consent_description": "โปรแกรมบนคอมพิวเตอร์เครื่องนี้ที่พอร์ต {port} กำลังขอการลงชื่อเข้าใช้ของ {username} ดำเนินการต่อเฉพาะเมื่อคุณเพิ่งกด \"ลงชื่อเข้าใช้ด้วยเบราว์เซอร์…\" ในแอป Bulwark บนเครื่องนี้ หากไม่ได้กด อาจมีคนพยายามเข้าบัญชีของคุณ ให้เลือก ไม่ใช่ตอนนี้", + "consent_confirm": "ลงชื่อเข้าใช้แอป", + "consent_cancel": "ไม่ใช่ตอนนี้", + "page_done": "คุณปิดแท็บนี้และกลับไปที่ Bulwark ได้", + "page_failed": "ลงชื่อเข้าใช้ไม่สำเร็จ กลับไปที่ Bulwark แล้วลองอีกครั้ง", + "page_link_used": "ลิงก์นี้ใช้ได้ครั้งเดียว กลับไปที่ Bulwark แล้วเริ่มใหม่", + "page_title": "ลงชื่อเข้าใช้ {server}", + "page_intro": "หน้านี้มาจากแอป Bulwark บนคอมพิวเตอร์เครื่องนี้ รหัสผ่านของคุณจะส่งไปที่ {server} เท่านั้น", + "page_username": "ที่อยู่อีเมลหรือชื่อผู้ใช้", + "page_password": "รหัสผ่าน", + "page_code": "รหัสสองปัจจัย", + "page_submit": "ลงชื่อเข้าใช้", + "page_wrong_password": "เซิร์ฟเวอร์ไม่ยอมรับชื่อผู้ใช้และรหัสผ่านนี้", + "page_code_needed": "บัญชีนี้ต้องใช้รหัสสองปัจจัยด้วย ป้อนรหัสผ่านอีกครั้งพร้อมรหัส", + "page_no_answer": "Bulwark ลงชื่อเข้าใช้ไม่ได้ ตรวจสอบว่าเข้าถึงเซิร์ฟเวอร์ได้แล้วลองอีกครั้ง", + "declined": "คุณปฏิเสธในเบราว์เซอร์ จึงไม่มีการลงชื่อเข้าใช้", + "too_many": "มีการพยายามลงชื่อเข้าใช้จากเครือข่ายนี้มากเกินไป รอสักครู่แล้วลองอีกครั้ง", + "page_declined": "ไม่มีการส่งมอบสิ่งใด คุณปิดแท็บนี้ได้" + }, "desktop_updates": { "title": "การอัปเดต", "version_label": "Bulwark {version}", @@ -6974,6 +7006,51 @@ "copy_failed": "คัดลอกไฟล์ไม่ได้", "reply_lost": "ไม่สามารถเก็บคำตอบที่พิมพ์ไว้ได้ เปิดข้อความแล้วตอบจากที่นั่น" }, + "desktopEdit": { + "edit": "แก้ไข", + "edit_named": "แก้ไข {name}", + "opened": "{name} เปิดอยู่ในโปรแกรมของไฟล์นั้น บันทึกการเปลี่ยนแปลงที่นั่น แล้วระบบจะถามว่าจะทำอย่างไรกับการเปลี่ยนแปลงนั้น", + "offers_label": "ไฟล์แนบที่แก้ไขแล้ว", + "changed_title": "{name} มีการเปลี่ยนแปลง", + "changed_body": "คุณบันทึกการเปลี่ยนแปลงในโปรแกรมอื่น จะให้ทำอย่างไรกับการเปลี่ยนแปลงนั้น", + "reply": "แนบไปกับการตอบกลับ", + "new_message": "แนบไปกับข้อความใหม่", + "open_draft": "เปิดฉบับร่าง", + "save_copy": "บันทึกสำเนา…", + "discard": "ละทิ้งการแก้ไข…", + "discard_title": "ละทิ้งการแก้ไขใน {name} หรือไม่", + "discard_detail": "สำเนาที่แก้ไขจะถูกลบ ไฟล์แนบต้นฉบับจะไม่เปลี่ยนแปลง", + "discard_confirm": "ละทิ้ง", + "cancel": "ยกเลิก", + "update": "อัปเดตไฟล์แนบ", + "no_app": "ไม่มีโปรแกรมในคอมพิวเตอร์นี้ที่เปิดไฟล์นี้ได้", + "blocked_type": "เปิดไฟล์ประเภทนี้เพื่อแก้ไขไม่ได้ เพราะอาจทำงานเป็นโปรแกรมได้", + "blocked_policy": "องค์กรของคุณไม่อนุญาตให้บันทึกไฟล์แนบในอุปกรณ์นี้", + "removed_policy": "องค์กรของคุณไม่อนุญาตอีกต่อไป จึงลบสำเนาที่แก้ไขของ {name} แล้ว", + "too_large": "ไฟล์นี้ใหญ่เกินกว่าจะเปิดเพื่อแก้ไข", + "too_many": "มีไฟล์ที่เปิดเพื่อแก้ไขมากเกินไป โปรดจัดการบางไฟล์ให้เสร็จก่อน", + "failed": "เปิดไฟล์เพื่อแก้ไขไม่ได้", + "read_busy": "โปรแกรมอื่นยังใช้ {name} อยู่ โปรดปิดที่นั่นแล้วลองอีกครั้ง", + "read_failed": "อ่านสำเนาที่แก้ไขของ {name} ไม่ได้", + "send_stale_title": "{name} มีการแก้ไขที่ไม่ได้อยู่ในข้อความนี้", + "send_stale_detail": "หากส่งตอนนี้ ข้อความจะส่งไปพร้อมเวอร์ชันเก่า การแก้ไขของคุณจะอยู่ในคอมพิวเตอร์นี้ได้นานสูงสุด 30 วัน จนกว่าคุณจะตัดสินใจ", + "send_stale_send": "ส่งต่อไป", + "send_stale_back": "ย้อนกลับ", + "signout_title": "ออกจากระบบและลบการแก้ไขหรือไม่", + "signout_confirm": "ออกจากระบบ", + "lock_note": "ไฟล์ที่เปิดด้วยแก้ไขจะยังเปิดอยู่ในโปรแกรมของไฟล์นั้นขณะที่แอปถูกล็อก และยังอ่านได้ในคอมพิวเตอร์นี้ ไฟล์ใหม่จะเปิดไม่ได้จนกว่าคุณจะปลดล็อก", + "changed_title_hidden": "บันทึกไฟล์แนบที่แก้ไขแล้ว", + "last_chance": "ไม่ได้ใช้มา 30 วัน สำเนานี้จะถูกลบพรุ่งนี้", + "held_named": "{names}: ยังเปิดอยู่ในโปรแกรมอื่น จึงยังลบสำเนาไม่ได้ ปิดที่นั่นแล้วสำเนาจะถูกลบ", + "held_unnamed": "สำเนาของไฟล์แนบยังเปิดอยู่ในโปรแกรมอื่น จึงยังลบไม่ได้ ปิดที่นั่นแล้วจะถูกลบ", + "removed_policy_held": "องค์กรของคุณไม่อนุญาตอีกต่อไป {name} ยังเปิดอยู่ในโปรแกรมอื่น จึงลบสำเนาได้หลังจากคุณปิดที่นั่นเท่านั้น", + "removed_policy_hidden": "องค์กรของคุณไม่อนุญาตให้แก้ไขไฟล์แนบอีกต่อไป จึงนำสำเนาที่แก้ไขออกแล้ว", + "outside_note": "แอปนี้ล้างไฟล์กู้คืนและรายการไฟล์ล่าสุดของโปรแกรมนั้นไม่ได้", + "clear_title": "ลบไฟล์แนบที่แก้ไขหรือไม่", + "clear_confirm": "ลบ", + "signout_changed": "ยังไม่ได้แนบหรือบันทึก และจะถูกลบตอนนี้: {names}", + "signout_held": "ยังเปิดอยู่ในโปรแกรมอื่น จึงจะถูกลบหลังจากคุณปิดที่นั่นเท่านั้น: {names}" + }, "desktopQuickCompose": { "label": "เขียนด่วน", "menu": "เขียนด่วน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 053804e73..03393f7f0 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Bu cihazda etkinleştirilmedi", "status_unsupported": "Bu tarayıcı Web Push'u desteklemiyor", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Arka plan bildirimleri demoda kullanılamaz.", "title": "Arka plan bildirimleri", "devices_title": "Kayıtlı cihazlar", "devices_desc": "Bu hesap için arka plan bildirimleri almak üzere kayıtlı tüm cihazlar. Birini iptal ettiğinizde o cihaz bildirimleri hemen almayı bırakır.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "Bu cihaz arka plan bildirimlerini almayı hemen bırakacak.", "shared_mailboxes_no_push": "Başkalarının sizinle paylaştığı posta kutuları arka plan bildirimi göndermez.", "error_forbidden": "Posta sunucusu bu hesap için arka plan bildirimlerine izin vermiyor.", - "error_over_quota": "Bu hesabın posta sunucusunda çok fazla push kaydı var (sınır genellikle 15'tir). Artık kullanmadığınız cihazları bildirim ayarlarından iptal edip yeniden deneyin." + "error_over_quota": "Bu hesabın posta sunucusunda çok fazla push kaydı var (sınır genellikle 15'tir). Artık kullanmadığınız cihazları bildirim ayarlarından iptal edip yeniden deneyin.", + "error_blocked": "Bu site için bildirimler engellendi. Tarayıcınızın site ayarlarından izin verin ve yeniden deneyin.", + "error_not_granted": "Bildirimlere izin verilmedi, bu yüzden anlık bildirim kapalı kalıyor.", + "error_verification_timeout": "Posta sunucusu bu cihazı zamanında onaylamadı, bu yüzden anlık bildirim kapalı kalıyor. Bir dakika sonra yeniden deneyin.", + "error_relay": "Anlık bildirim aktarıcısına şu anda ulaşılamıyor, bu yüzden anlık bildirim kapalı kalıyor. Daha sonra yeniden deneyin.", + "error_teardown": "Anlık bildirim bu tarayıcıda kapatıldı, ancak posta sunucusuna veya aktarıcıya ulaşılamadı. Bildirimler birkaç gün daha gelebilir. Daha erken durdurmak için yeniden çevrimiçi olduğunuzda gelişmiş ayarları açın ve bu cihazı Kayıtlı cihazlar bölümünden iptal edin." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Çok fazla deneme. {count, plural, one {# dakika} other {# dakika}} sonra yeniden deneyin." }, "desktop_sign_in": { - "browser_button": "Tarayıcıyla oturum aç", - "browser_waiting": "Oturum açmayı tarayıcınızda tamamlayın", - "browser_unavailable": "Bu sunucu tarayıcıyla oturum açmayı desteklemiyor. Parolanızı kullanın.", - "browser_failed": "Tarayıcıyla oturum açılamadı. Yeniden deneyin.", "server_needed": "Önce sunucu adresini girin.", "server_declined": "Bu sunucuya izin vermediniz, bu yüzden ona hiçbir şey gönderilmedi.", "server_invalid": "Bu sunucu adresi geçerli değil." }, + "desktop_browser_sign_in": { + "button": "Tarayıcınızla oturum açın…", + "waiting": "Tarayıcınız bekleniyor…", + "waiting_hint": "Oturum açmayı orada tamamlayın. Parola yöneticiniz tarayıcınızda çalışır.", + "cancel": "İptal", + "or_here": "veya burada oturum açın", + "timed_out": "Tarayıcıda oturum açma çok uzun sürdü. Yeniden deneyin.", + "no_browser": "Bulwark tarayıcınızı açamadı. Yeniden deneyin veya burada oturum açın.", + "failed": "Tarayıcınız üzerinden oturum açılamadı. Yeniden deneyin.", + "handoff_banner": "Bu bilgisayardaki bir program bu oturum açmayı Bulwark uygulaması için istedi. Bu tarayıcı Bulwark’ta oturum kapalı kalır. Bunu siz başlatmadıysanız bu sekmeyi kapatın.", + "consent_title": "Bulwark uygulamasında oturum açılsın mı?", + "consent_description": "Bu bilgisayardaki bir program, {port} bağlantı noktasında, {username} için oturum açma bilgisini istiyor. Yalnızca buradaki Bulwark uygulamasında az önce \"Tarayıcınızla oturum açın…\" düğmesine bastıysanız devam edin. Basmadıysanız biri hesabınıza girmeye çalışıyor olabilir: Şimdi değil seçeneğini seçin.", + "consent_confirm": "Uygulamada oturum aç", + "consent_cancel": "Şimdi değil", + "page_done": "Bu sekmeyi kapatıp Bulwark’a dönebilirsiniz.", + "page_failed": "Oturum açılamadı. Bulwark’a dönün ve yeniden deneyin.", + "page_link_used": "Bu bağlantı yalnızca bir kez çalışır. Bulwark’a dönün ve yeniden başlayın.", + "page_title": "{server} için oturum açın", + "page_intro": "Bu sayfa bu bilgisayardaki Bulwark uygulamasından geliyor. Parolanız {server} sunucusuna gider, başka hiçbir yere gitmez.", + "page_username": "E-posta adresi veya kullanıcı adı", + "page_password": "Parola", + "page_code": "İki aşamalı kod", + "page_submit": "Oturum aç", + "page_wrong_password": "Sunucu bu kullanıcı adını ve parolayı kabul etmedi.", + "page_code_needed": "Bu hesap ayrıca iki aşamalı kod istiyor. Parolanızı ve kodu yeniden girin.", + "page_no_answer": "Bulwark oturum açamadı. Sunucunun erişilebilir olduğunu denetleyin ve yeniden deneyin.", + "declined": "Tarayıcınızda hayır dediniz, bu yüzden oturum açılmadı.", + "too_many": "Bu ağdan çok fazla oturum açma denemesi yapıldı. Birkaç dakika bekleyip yeniden deneyin.", + "page_declined": "Hiçbir şey aktarılmadı. Bu sekmeyi kapatabilirsiniz." + }, "desktop_updates": { "title": "Güncellemeler", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Dosya kopyalanamadı.", "reply_lost": "Yazdığınız yanıt korunamadı. İletiyi açıp oradan yanıtlayın." }, + "desktopEdit": { + "edit": "Düzenle", + "edit_named": "{name} dosyasını düzenle", + "opened": "{name} kendi programında açık. Değişikliklerinizi orada kaydedin, ardından bunlarla ne yapılacağı size sorulur.", + "offers_label": "Düzenlenen ekler", + "changed_title": "{name} değişti", + "changed_body": "Başka bir programda değişiklik kaydettiniz. Bunlarla ne yapılsın?", + "reply": "Bir yanıta ekle", + "new_message": "Yeni bir iletiye ekle", + "open_draft": "Taslağı aç", + "save_copy": "Kopya kaydet…", + "discard": "Değişikliklerden vazgeç…", + "discard_title": "{name} üzerindeki değişikliklerden vazgeçilsin mi?", + "discard_detail": "Düzenlenen kopya silinir. Asıl ek değişmez.", + "discard_confirm": "Vazgeç", + "cancel": "İptal", + "update": "Eki güncelle", + "no_app": "Bu bilgisayardaki hiçbir program bu dosyayı açamıyor.", + "blocked_type": "Bu dosya türü düzenlemek için açılamaz, çünkü program olarak çalışabilir.", + "blocked_policy": "Kuruluşunuz bu cihaza ek kaydedilmesine izin vermiyor.", + "removed_policy": "Kuruluşunuz artık buna izin vermiyor, bu yüzden {name} dosyasının düzenlenen kopyası silindi.", + "too_large": "Bu dosya düzenlemek için açılamayacak kadar büyük.", + "too_many": "Düzenlemek için çok fazla dosya açık. Önce bazılarını bitirin.", + "failed": "Dosya düzenlemek için açılamadı.", + "read_busy": "{name} hâlâ başka bir program tarafından kullanılıyor. Orada kapatıp yeniden deneyin.", + "read_failed": "{name} dosyasının düzenlenen kopyası okunamadı.", + "send_stale_title": "{name} dosyasında bu iletide olmayan değişiklikler var", + "send_stale_detail": "Şimdi gönderirseniz ileti eski sürümle gider. Değişiklikleriniz, ne yapacağınıza karar verene kadar en fazla 30 gün bu bilgisayarda kalır.", + "send_stale_send": "Yine de gönder", + "send_stale_back": "Geri dön", + "signout_title": "Oturum kapatılıp değişiklikler silinsin mi?", + "signout_confirm": "Oturumu kapat", + "lock_note": "Düzenle ile açtığınız dosyalar uygulama kilitliyken kendi programlarında açık kalır ve bu bilgisayarda okunabilir olmayı sürdürür. Yenileri kilidi açana kadar açılamaz.", + "changed_title_hidden": "Düzenlenen bir ek kaydedildi", + "last_chance": "30 gündür kullanılmadı. Bu kopya yarın silinecek.", + "held_named": "{names}: hâlâ başka bir programda açık, bu yüzden kopya henüz silinemiyor. Orada kapatırsanız silinir.", + "held_unnamed": "Bir ekin kopyası hâlâ başka bir programda açık ve henüz silinemiyor. Orada kapatırsanız silinir.", + "removed_policy_held": "Kuruluşunuz artık buna izin vermiyor. {name} hâlâ başka bir programda açık, bu yüzden kopya ancak orada kapatınca silinebilir.", + "removed_policy_hidden": "Kuruluşunuz artık ekleri düzenlemeye izin vermiyor, bu yüzden düzenlenen bir kopya kaldırıldı.", + "outside_note": "O programın kurtarma dosyalarını ve son dosyalar listesini bu uygulama temizleyemez.", + "clear_title": "Düzenlenen ekler silinsin mi?", + "clear_confirm": "Sil", + "signout_changed": "Henüz eklenmedi veya kaydedilmedi, şimdi siliniyor: {names}.", + "signout_held": "Hâlâ başka bir programda açık, bu yüzden ancak orada kapatınca silinir: {names}." + }, "desktopQuickCompose": { "label": "Hızlı ileti", "menu": "Hızlı ileti", diff --git a/locales/uk/common.json b/locales/uk/common.json index 39971a583..f033cd1ee 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "Не ввімкнено на цьому пристрої", "status_unsupported": "Цей браузер не підтримує Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "Фонові сповіщення недоступні в демоверсії.", "title": "Фонові сповіщення", "devices_title": "Зареєстровані пристрої", "devices_desc": "Усі пристрої, зареєстровані для отримання фонових сповіщень цього облікового запису. Після відкликання пристрій одразу перестає їх отримувати.", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "Цей пристрій одразу перестане отримувати фонові сповіщення.", "shared_mailboxes_no_push": "Поштові скриньки, до яких вам надали доступ інші користувачі, не надсилають фонових сповіщень.", "error_forbidden": "Поштовий сервер не дозволяє фонові сповіщення для цього облікового запису.", - "error_over_quota": "Цей обліковий запис має забагато push-реєстрацій на поштовому сервері (зазвичай дозволено 15). Відкличте в налаштуваннях сповіщень пристрої, якими ви більше не користуєтеся, і спробуйте ще раз." + "error_over_quota": "Цей обліковий запис має забагато push-реєстрацій на поштовому сервері (зазвичай дозволено 15). Відкличте в налаштуваннях сповіщень пристрої, якими ви більше не користуєтеся, і спробуйте ще раз.", + "error_blocked": "Сповіщення для цього сайту заблоковано. Дозвольте їх у налаштуваннях сайту в браузері та спробуйте ще раз.", + "error_not_granted": "Сповіщення не дозволено, тому push залишається вимкненим.", + "error_verification_timeout": "Поштовий сервер не підтвердив цей пристрій вчасно, тому push залишається вимкненим. Спробуйте ще раз за хвилину.", + "error_relay": "Push-ретранслятор зараз недоступний, тому push залишається вимкненим. Спробуйте пізніше.", + "error_teardown": "Push у цьому браузері вимкнено, але поштовий сервер або ретранслятор були недоступні. Сповіщення можуть надходити ще кілька днів. Щоб зупинити їх раніше, коли знову будете онлайн, увімкніть розширені налаштування й відкличте цей пристрій у розділі «Зареєстровані пристрої»." } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "Забагато спроб. Повторіть через {count, plural, one {# хвилину} few {# хвилини} many {# хвилин} other {# хвилини}}." }, "desktop_sign_in": { - "browser_button": "Увійти через браузер", - "browser_waiting": "Завершіть вхід у браузері", - "browser_unavailable": "Цей сервер не підтримує вхід через браузер. Використайте пароль.", - "browser_failed": "Не вдалося увійти через браузер. Спробуйте ще раз.", "server_needed": "Спочатку введіть адресу сервера.", "server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.", "server_invalid": "Ця адреса сервера недійсна." }, + "desktop_browser_sign_in": { + "button": "Увійти через браузер…", + "waiting": "Очікування браузера…", + "waiting_hint": "Завершіть вхід там. Ваш менеджер паролів працює в браузері.", + "cancel": "Скасувати", + "or_here": "або увійдіть тут", + "timed_out": "Вхід через браузер тривав надто довго. Спробуйте ще раз.", + "no_browser": "Bulwark не зміг відкрити браузер. Спробуйте ще раз або увійдіть тут.", + "failed": "Не вдалося увійти через браузер. Спробуйте ще раз.", + "handoff_banner": "Програма на цьому комп’ютері запитала цей вхід для застосунку Bulwark. У цьому браузері вхід у Bulwark не виконується. Якщо це почали не ви, закрийте вкладку.", + "consent_title": "Увійти в застосунок Bulwark?", + "consent_description": "Програма на цьому комп’ютері, на порту {port}, запитує вхід для {username}. Продовжуйте, лише якщо ви щойно натиснули «Увійти через браузер…» у застосунку Bulwark на цьому комп’ютері. Якщо ні, можливо, хтось намагається потрапити у ваш обліковий запис: виберіть «Не зараз».", + "consent_confirm": "Увійти в застосунок", + "consent_cancel": "Не зараз", + "page_done": "Можна закрити цю вкладку й повернутися до Bulwark.", + "page_failed": "Не вдалося увійти. Поверніться до Bulwark і спробуйте ще раз.", + "page_link_used": "Це посилання працює лише один раз. Поверніться до Bulwark і почніть знову.", + "page_title": "Вхід на {server}", + "page_intro": "Цю сторінку відкрив застосунок Bulwark на цьому комп’ютері. Ваш пароль іде на {server} і більше нікуди.", + "page_username": "Адреса ел. пошти або ім’я користувача", + "page_password": "Пароль", + "page_code": "Двофакторний код", + "page_submit": "Увійти", + "page_wrong_password": "Сервер не прийняв це ім’я користувача й пароль.", + "page_code_needed": "Для цього облікового запису також потрібен двофакторний код. Введіть пароль ще раз і код.", + "page_no_answer": "Bulwark не зміг увійти. Перевірте, чи доступний сервер, і спробуйте ще раз.", + "declined": "Ви відмовили в браузері, тому вхід не виконано.", + "too_many": "Забагато спроб входу з цієї мережі. Зачекайте кілька хвилин і спробуйте ще раз.", + "page_declined": "Нічого не передано. Можна закрити цю вкладку." + }, "desktop_updates": { "title": "Оновлення", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "Не вдалося скопіювати файл.", "reply_lost": "Не вдалося зберегти введену відповідь. Відкрийте лист і дайте відповідь у ньому." }, + "desktopEdit": { + "edit": "Редагувати", + "edit_named": "Редагувати {name}", + "opened": "{name} відкрито у своїй програмі. Збережіть там зміни, і вас запитають, що з ними робити.", + "offers_label": "Відредаговані вкладення", + "changed_title": "{name} змінено", + "changed_body": "Ви зберегли зміни в іншій програмі. Що з ними зробити?", + "reply": "Вкласти у відповідь", + "new_message": "Вкласти в новий лист", + "open_draft": "Відкрити чернетку", + "save_copy": "Зберегти копію…", + "discard": "Відкинути зміни…", + "discard_title": "Відкинути зміни в {name}?", + "discard_detail": "Відредаговану копію буде видалено. Початкове вкладення не зміниться.", + "discard_confirm": "Відкинути", + "cancel": "Скасувати", + "update": "Оновити вкладення", + "no_app": "Жодна програма на цьому комп’ютері не може відкрити цей файл.", + "blocked_type": "Файл такого типу не можна відкрити для редагування, бо він може запуститися як програма.", + "blocked_policy": "Ваша організація не дозволяє зберігати вкладення на цьому пристрої.", + "removed_policy": "Ваша організація більше цього не дозволяє, тому відредаговану копію {name} видалено.", + "too_large": "Цей файл завеликий, щоб відкрити його для редагування.", + "too_many": "Для редагування відкрито забагато файлів. Спершу завершіть роботу з деякими з них.", + "failed": "Не вдалося відкрити файл для редагування.", + "read_busy": "Інша програма ще використовує {name}. Закрийте його там і спробуйте ще раз.", + "read_failed": "Не вдалося прочитати відредаговану копію {name}.", + "send_stale_title": "У {name} є зміни, яких немає в цьому листі", + "send_stale_detail": "Якщо надіслати зараз, лист піде зі старішою версією. Ваші зміни залишаться на цьому комп’ютері до 30 днів, доки ви не вирішите, що з ними робити.", + "send_stale_send": "Усе одно надіслати", + "send_stale_back": "Назад", + "signout_title": "Вийти й видалити зміни?", + "signout_confirm": "Вийти", + "lock_note": "Файли, відкриті через «Редагувати», залишаються відкритими у своїй програмі, поки застосунок заблоковано, і їх усе ще можна прочитати на цьому комп’ютері. Нові не можна відкрити, доки ви не розблокуєте застосунок.", + "changed_title_hidden": "Відредаговане вкладення збережено", + "last_chance": "Не використовувалося 30 днів. Цю копію буде видалено завтра.", + "held_named": "{names}: ще відкрито в іншій програмі, тому копію поки не можна видалити. Закрийте там, і її буде видалено.", + "held_unnamed": "Копія вкладення ще відкрита в іншій програмі, і її поки не можна видалити. Закрийте її там, і її буде видалено.", + "removed_policy_held": "Ваша організація більше цього не дозволяє. {name} ще відкрито в іншій програмі, тому копію можна видалити лише після закриття там.", + "removed_policy_hidden": "Ваша організація більше не дозволяє редагувати вкладення, тому відредаговану копію вилучено.", + "outside_note": "Файли відновлення та список нещодавніх файлів тієї програми цей застосунок очистити не може.", + "clear_title": "Видалити відредаговані вкладення?", + "clear_confirm": "Видалити", + "signout_changed": "Ще не вкладено й не збережено, і зараз видаляється: {names}.", + "signout_held": "Ще відкрито в іншій програмі, тому видалиться лише після закриття там: {names}." + }, "desktopQuickCompose": { "label": "Швидкий лист", "menu": "Швидкий лист", diff --git a/locales/vi/common.json b/locales/vi/common.json index ab0ab912d..9faa09bde 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -1752,6 +1752,7 @@ "status_inactive": "Chưa bật trên thiết bị này", "status_unsupported": "Trình duyệt này không hỗ trợ Web Push", "status_lite": "Bulwark Lite không hỗ trợ Web Push; tính năng này cần máy chủ Bulwark.", + "status_demo": "Thông báo nền không khả dụng trong bản demo.", "status_busy": "Đang xử lý…", "enable": "Bật", "reenable": "Đăng ký lại", @@ -1778,7 +1779,12 @@ "confirm_revoke_message_this": "Thiết bị này sẽ ngừng nhận thông báo chạy nền ngay lập tức.", "shared_mailboxes_no_push": "Hộp thư được người khác chia sẻ với bạn không gửi thông báo chạy nền.", "error_forbidden": "Máy chủ thư không cho phép bật thông báo chạy nền cho tài khoản này.", - "error_over_quota": "Tài khoản này có quá nhiều lượt đăng ký push trên máy chủ thư (giới hạn thường là 15). Hãy thu hồi các thiết bị không còn dùng trong phần cài đặt thông báo rồi thử lại." + "error_over_quota": "Tài khoản này có quá nhiều lượt đăng ký push trên máy chủ thư (giới hạn thường là 15). Hãy thu hồi các thiết bị không còn dùng trong phần cài đặt thông báo rồi thử lại.", + "error_blocked": "Thông báo đang bị chặn cho trang này. Hãy cho phép trong cài đặt trang của trình duyệt rồi thử lại.", + "error_not_granted": "Thông báo không được cho phép nên push vẫn tắt.", + "error_verification_timeout": "Máy chủ thư không xác nhận thiết bị này kịp thời nên push vẫn tắt. Hãy thử lại sau một phút.", + "error_relay": "Hiện không thể kết nối với relay push nên push vẫn tắt. Hãy thử lại sau.", + "error_teardown": "Push đã tắt trong trình duyệt này nhưng không thể kết nối với máy chủ thư hoặc relay. Thông báo có thể tiếp tục đến trong vài ngày. Để dừng sớm hơn, khi có mạng trở lại hãy bật cài đặt nâng cao và thu hồi thiết bị này trong Thiết bị đã đăng ký." }, "push_all": { "title": "Tất cả tài khoản", @@ -6885,14 +6891,40 @@ "blocked_minutes": "Quá nhiều lần thử. Hãy thử lại sau {count, plural, other {# phút}}." }, "desktop_sign_in": { - "browser_button": "Đăng nhập bằng trình duyệt", - "browser_waiting": "Hoàn tất đăng nhập trong trình duyệt", - "browser_unavailable": "Máy chủ này không hỗ trợ đăng nhập qua trình duyệt. Hãy dùng mật khẩu.", - "browser_failed": "Không thể đăng nhập qua trình duyệt. Hãy thử lại.", "server_needed": "Trước tiên hãy nhập địa chỉ máy chủ.", "server_declined": "Bạn không cho phép máy chủ này nên không có gì được gửi đến đó.", "server_invalid": "Địa chỉ máy chủ này không hợp lệ." }, + "desktop_browser_sign_in": { + "button": "Đăng nhập bằng trình duyệt…", + "waiting": "Đang chờ trình duyệt…", + "waiting_hint": "Hãy hoàn tất đăng nhập ở đó. Trình quản lý mật khẩu của bạn hoạt động trong trình duyệt.", + "cancel": "Hủy", + "or_here": "hoặc đăng nhập tại đây", + "timed_out": "Đăng nhập trong trình duyệt mất quá nhiều thời gian. Hãy thử lại.", + "no_browser": "Bulwark không thể mở trình duyệt. Hãy thử lại hoặc đăng nhập tại đây.", + "failed": "Không đăng nhập được qua trình duyệt. Hãy thử lại.", + "handoff_banner": "Một chương trình trên máy tính này đã yêu cầu đăng nhập này cho ứng dụng Bulwark. Trình duyệt này vẫn chưa đăng nhập Bulwark. Nếu không phải bạn bắt đầu, hãy đóng tab này.", + "consent_title": "Đăng nhập vào ứng dụng Bulwark?", + "consent_description": "Một chương trình trên máy tính này, tại cổng {port}, đang yêu cầu thông tin đăng nhập của {username}. Chỉ tiếp tục nếu bạn vừa nhấn \"Đăng nhập bằng trình duyệt…\" trong ứng dụng Bulwark tại đây. Nếu không, có thể ai đó đang cố vào tài khoản của bạn: hãy chọn Để sau.", + "consent_confirm": "Đăng nhập vào ứng dụng", + "consent_cancel": "Để sau", + "page_done": "Bạn có thể đóng tab này và quay lại Bulwark.", + "page_failed": "Không đăng nhập được. Hãy quay lại Bulwark và thử lại.", + "page_link_used": "Liên kết này chỉ dùng được một lần. Hãy quay lại Bulwark và bắt đầu lại.", + "page_title": "Đăng nhập vào {server}", + "page_intro": "Trang này đến từ ứng dụng Bulwark trên máy tính này. Mật khẩu của bạn chỉ được gửi đến {server}.", + "page_username": "Địa chỉ email hoặc tên người dùng", + "page_password": "Mật khẩu", + "page_code": "Mã hai yếu tố", + "page_submit": "Đăng nhập", + "page_wrong_password": "Máy chủ không chấp nhận tên người dùng và mật khẩu này.", + "page_code_needed": "Tài khoản này cũng cần mã hai yếu tố. Hãy nhập lại mật khẩu và mã.", + "page_no_answer": "Bulwark không thể đăng nhập. Hãy kiểm tra xem máy chủ có truy cập được không rồi thử lại.", + "declined": "Bạn đã từ chối trong trình duyệt nên không có đăng nhập nào.", + "too_many": "Có quá nhiều lần thử đăng nhập từ mạng này. Hãy đợi vài phút rồi thử lại.", + "page_declined": "Không có gì được chuyển giao. Bạn có thể đóng tab này." + }, "desktop_updates": { "title": "Cập nhật", "version_label": "Bulwark {version}", @@ -6978,6 +7010,51 @@ "copy_failed": "Không thể sao chép tệp.", "reply_lost": "Không thể giữ câu trả lời bạn đã nhập. Hãy mở thư và trả lời ở đó." }, + "desktopEdit": { + "edit": "Chỉnh sửa", + "edit_named": "Chỉnh sửa {name}", + "opened": "{name} đang mở trong chương trình của nó. Hãy lưu thay đổi ở đó, rồi bạn sẽ được hỏi muốn làm gì với chúng.", + "offers_label": "Tệp đính kèm đã chỉnh sửa", + "changed_title": "{name} đã thay đổi", + "changed_body": "Bạn đã lưu thay đổi trong một chương trình khác. Bạn muốn làm gì với chúng?", + "reply": "Đính kèm vào thư trả lời", + "new_message": "Đính kèm vào thư mới", + "open_draft": "Mở bản nháp", + "save_copy": "Lưu một bản sao…", + "discard": "Bỏ thay đổi…", + "discard_title": "Bỏ các thay đổi của bạn trong {name}?", + "discard_detail": "Bản sao đã chỉnh sửa sẽ bị xóa. Tệp đính kèm gốc không thay đổi.", + "discard_confirm": "Bỏ", + "cancel": "Hủy", + "update": "Cập nhật tệp đính kèm", + "no_app": "Không có chương trình nào trên máy tính này mở được tệp này.", + "blocked_type": "Không thể mở loại tệp này để chỉnh sửa vì nó có thể chạy như một chương trình.", + "blocked_policy": "Tổ chức của bạn không cho phép lưu tệp đính kèm trên thiết bị này.", + "removed_policy": "Tổ chức của bạn không còn cho phép việc này, nên bản sao đã chỉnh sửa của {name} đã bị xóa.", + "too_large": "Tệp này quá lớn để mở chỉnh sửa.", + "too_many": "Có quá nhiều tệp đang mở để chỉnh sửa. Hãy hoàn tất một vài tệp trước.", + "failed": "Không thể mở tệp để chỉnh sửa.", + "read_busy": "Một chương trình khác vẫn đang dùng {name}. Hãy đóng nó ở đó rồi thử lại.", + "read_failed": "Không đọc được bản sao đã chỉnh sửa của {name}.", + "send_stale_title": "{name} có thay đổi không nằm trong thư này", + "send_stale_detail": "Nếu gửi ngay, thư sẽ đi với phiên bản cũ hơn. Thay đổi của bạn ở trên máy tính này tối đa 30 ngày, cho đến khi bạn quyết định.", + "send_stale_send": "Vẫn gửi", + "send_stale_back": "Quay lại", + "signout_title": "Đăng xuất và xóa thay đổi của bạn?", + "signout_confirm": "Đăng xuất", + "lock_note": "Các tệp đã mở bằng Chỉnh sửa vẫn mở trong chương trình của chúng khi ứng dụng bị khóa và vẫn đọc được trên máy tính này. Không thể mở tệp mới cho đến khi bạn mở khóa.", + "changed_title_hidden": "Một tệp đính kèm đã chỉnh sửa đã được lưu", + "last_chance": "Không dùng trong 30 ngày. Bản sao này sẽ bị xóa vào ngày mai.", + "held_named": "{names}: vẫn đang mở trong chương trình khác nên chưa xóa được bản sao. Hãy đóng ở đó và bản sao sẽ bị xóa.", + "held_unnamed": "Bản sao của một tệp đính kèm vẫn đang mở trong chương trình khác nên chưa xóa được. Hãy đóng ở đó và bản sao sẽ bị xóa.", + "removed_policy_held": "Tổ chức của bạn không còn cho phép việc này. {name} vẫn đang mở trong chương trình khác nên chỉ xóa được bản sao sau khi bạn đóng ở đó.", + "removed_policy_hidden": "Tổ chức của bạn không còn cho phép chỉnh sửa tệp đính kèm nên một bản sao đã chỉnh sửa đã bị gỡ.", + "outside_note": "Ứng dụng này không thể xóa các tệp khôi phục và danh sách tệp gần đây của chương trình đó.", + "clear_title": "Xóa các tệp đính kèm đã chỉnh sửa?", + "clear_confirm": "Xóa", + "signout_changed": "Chưa đính kèm hoặc lưu, và bị xóa ngay: {names}.", + "signout_held": "Vẫn đang mở trong chương trình khác nên chỉ bị xóa sau khi bạn đóng ở đó: {names}." + }, "desktopQuickCompose": { "label": "Soạn nhanh", "menu": "Soạn nhanh", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index edc89d86b..eed67bf21 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1740,6 +1740,7 @@ "status_inactive": "在此裝置上未啟用", "status_unsupported": "此瀏覽器不支援 Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "展示版不提供背景通知。", "status_busy": "工作中…", "enable": "啟用", "reenable": "重新註冊", @@ -1766,7 +1767,12 @@ "confirm_revoke_message_this": "此裝置會立即停止接收背景通知。", "shared_mailboxes_no_push": "其他人與您共用的信箱不會傳送背景通知。", "error_forbidden": "郵件伺服器不允許此帳號使用背景通知。", - "error_over_quota": "此帳號在郵件伺服器上的推送註冊過多(上限通常為 15 個)。請在通知設定中撤銷不再使用的裝置,然後再試一次。" + "error_over_quota": "此帳號在郵件伺服器上的推送註冊過多(上限通常為 15 個)。請在通知設定中撤銷不再使用的裝置,然後再試一次。", + "error_blocked": "此網站的通知已被封鎖。請在瀏覽器的網站設定中允許通知,然後再試一次。", + "error_not_granted": "未允許通知,因此推播保持關閉。", + "error_verification_timeout": "郵件伺服器未及時確認此裝置,因此推播保持關閉。請一分鐘後再試一次。", + "error_relay": "目前無法連線到推播中繼,因此推播保持關閉。請稍後再試。", + "error_teardown": "此瀏覽器的推播已關閉,但無法連線到郵件伺服器或中繼。通知可能還會持續幾天。如需盡快停止,請在恢復連線後開啟進階設定,並在已註冊裝置中撤銷此裝置。" }, "push_all": { "title": "All accounts", @@ -6873,14 +6879,40 @@ "blocked_minutes": "嘗試次數過多。請在 {count, plural, other {# 分鐘}}後再試。" }, "desktop_sign_in": { - "browser_button": "使用瀏覽器登入", - "browser_waiting": "請在瀏覽器中完成登入", - "browser_unavailable": "此伺服器不支援透過瀏覽器登入。請使用密碼。", - "browser_failed": "無法透過瀏覽器登入。請再試一次。", "server_needed": "請先輸入伺服器位址。", "server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。", "server_invalid": "此伺服器位址無效。" }, + "desktop_browser_sign_in": { + "button": "使用瀏覽器登入…", + "waiting": "正在等候瀏覽器…", + "waiting_hint": "請在瀏覽器中完成登入。你的密碼管理工具可以在瀏覽器中使用。", + "cancel": "取消", + "or_here": "或在此登入", + "timed_out": "瀏覽器登入耗時過長。請再試一次。", + "no_browser": "Bulwark 無法開啟瀏覽器。請再試一次,或在此登入。", + "failed": "透過瀏覽器登入失敗。請再試一次。", + "handoff_banner": "這台電腦上的一個程式為 Bulwark 應用程式要求了這次登入。此瀏覽器不會登入 Bulwark。如果不是你發起的,請關閉此分頁。", + "consent_title": "要登入 Bulwark 應用程式嗎?", + "consent_description": "這台電腦上連接埠 {port} 的一個程式正在要求 {username} 的登入資訊。只有在你剛剛按下這裡 Bulwark 應用程式中的「使用瀏覽器登入…」時才繼續。否則可能有人想進入你的帳戶:請選擇「暫不」。", + "consent_confirm": "登入應用程式", + "consent_cancel": "暫不", + "page_done": "你可以關閉此分頁並返回 Bulwark。", + "page_failed": "登入失敗。請返回 Bulwark 並再試一次。", + "page_link_used": "此連結只能使用一次。請返回 Bulwark 重新開始。", + "page_title": "登入 {server}", + "page_intro": "此頁面來自這台電腦上的 Bulwark 應用程式。你的密碼只會傳送到 {server}。", + "page_username": "電子郵件地址或使用者名稱", + "page_password": "密碼", + "page_code": "雙重驗證碼", + "page_submit": "登入", + "page_wrong_password": "伺服器不接受此使用者名稱和密碼。", + "page_code_needed": "此帳戶還需要雙重驗證碼。請重新輸入密碼和驗證碼。", + "page_no_answer": "Bulwark 無法登入。請檢查伺服器是否可連線,然後再試一次。", + "declined": "你在瀏覽器中拒絕了,因此沒有登入。", + "too_many": "來自此網路的登入嘗試過多。請等幾分鐘後再試一次。", + "page_declined": "沒有交出任何內容。你可以關閉此分頁。" + }, "desktop_updates": { "title": "更新", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "無法複製檔案。", "reply_lost": "無法保留您輸入的回覆。請開啟郵件並在其中回覆。" }, + "desktopEdit": { + "edit": "編輯", + "edit_named": "編輯 {name}", + "opened": "{name} 已在其程式中開啟。請在那裡儲存變更,之後會詢問你要如何處理這些變更。", + "offers_label": "已編輯的附件", + "changed_title": "{name} 已變更", + "changed_body": "你在其他程式中儲存了變更。要如何處理?", + "reply": "附加到回覆", + "new_message": "附加到新郵件", + "open_draft": "開啟草稿", + "save_copy": "儲存複本…", + "discard": "捨棄編輯…", + "discard_title": "要捨棄對 {name} 的編輯嗎?", + "discard_detail": "編輯後的複本會被刪除。原始附件不會改變。", + "discard_confirm": "捨棄", + "cancel": "取消", + "update": "更新附件", + "no_app": "這部電腦上沒有程式能開啟此檔案。", + "blocked_type": "此類檔案可能以程式方式執行,因此無法開啟進行編輯。", + "blocked_policy": "你的組織不允許在此裝置上儲存附件。", + "removed_policy": "你的組織已不再允許此操作,因此 {name} 的編輯複本已被刪除。", + "too_large": "此檔案太大,無法開啟進行編輯。", + "too_many": "開啟編輯的檔案太多。請先處理完其中一些。", + "failed": "無法開啟檔案進行編輯。", + "read_busy": "其他程式仍在使用 {name}。請在那裡關閉後再試一次。", + "read_failed": "無法讀取 {name} 的編輯複本。", + "send_stale_title": "{name} 有未包含在此郵件中的編輯", + "send_stale_detail": "如果現在傳送,郵件將使用舊版本。你的編輯最多在這部電腦上保留 30 天,直到你決定如何處理。", + "send_stale_send": "仍要傳送", + "send_stale_back": "返回", + "signout_title": "要登出並刪除你的編輯嗎?", + "signout_confirm": "登出", + "lock_note": "應用程式鎖定期間,透過「編輯」開啟的檔案仍在其程式中保持開啟,並且在這部電腦上仍可讀取。解鎖前無法開啟新檔案。", + "changed_title_hidden": "已儲存編輯過的附件", + "last_chance": "30 天未使用。此複本將於明天刪除。", + "held_named": "{names}:仍在其他程式中開啟,因此複本暫時無法刪除。在那裡關閉後就會刪除。", + "held_unnamed": "某個附件的複本仍在其他程式中開啟,暫時無法刪除。在那裡關閉後就會刪除。", + "removed_policy_held": "你的組織已不再允許此操作。{name} 仍在其他程式中開啟,因此要在那裡關閉後才能刪除複本。", + "removed_policy_hidden": "你的組織已不再允許編輯附件,因此已移除一個編輯複本。", + "outside_note": "該程式本身的復原檔案和最近使用的檔案清單不在此應用程式可清除的範圍內。", + "clear_title": "要刪除編輯過的附件嗎?", + "clear_confirm": "刪除", + "signout_changed": "尚未附加或儲存,現在將刪除:{names}。", + "signout_held": "仍在其他程式中開啟,因此要在那裡關閉後才會刪除:{names}。" + }, "desktopQuickCompose": { "label": "快速撰寫", "menu": "快速撰寫", diff --git a/locales/zh/common.json b/locales/zh/common.json index bc5ffd4e2..0edeb8c81 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1780,6 +1780,7 @@ "status_inactive": "在此设备上未启用", "status_unsupported": "此浏览器不支持 Web Push", "status_lite": "Web Push is not available in Bulwark Lite; it needs the Bulwark server.", + "status_demo": "演示版不提供后台通知。", "title": "后台通知", "devices_title": "已注册的设备", "devices_desc": "已注册接收此账户后台通知的所有设备。撤销后,该设备会立即停止接收。", @@ -1799,7 +1800,12 @@ "confirm_revoke_message_this": "此设备将立即停止接收后台通知。", "shared_mailboxes_no_push": "他人与您共享的邮箱不会发送后台通知。", "error_forbidden": "邮件服务器不允许此账户使用后台通知。", - "error_over_quota": "此账户在邮件服务器上的推送注册过多(上限通常为 15 个)。请在通知设置中撤销不再使用的设备,然后重试。" + "error_over_quota": "此账户在邮件服务器上的推送注册过多(上限通常为 15 个)。请在通知设置中撤销不再使用的设备,然后重试。", + "error_blocked": "此网站的通知已被阻止。请在浏览器的网站设置中允许通知,然后重试。", + "error_not_granted": "未允许通知,因此推送保持关闭。", + "error_verification_timeout": "邮件服务器未及时确认此设备,因此推送保持关闭。请一分钟后重试。", + "error_relay": "目前无法连接推送中继,因此推送保持关闭。请稍后重试。", + "error_teardown": "此浏览器的推送已关闭,但无法连接邮件服务器或中继。通知可能还会持续几天。如需尽快停止,请在恢复联网后打开高级设置,并在已注册设备中撤销此设备。" } }, "language_region": { @@ -6873,14 +6879,40 @@ "blocked_minutes": "尝试次数过多。请在 {count, plural, other {# 分钟}}后重试。" }, "desktop_sign_in": { - "browser_button": "使用浏览器登录", - "browser_waiting": "请在浏览器中完成登录", - "browser_unavailable": "此服务器不支持通过浏览器登录。请使用密码。", - "browser_failed": "无法通过浏览器登录。请重试。", "server_needed": "请先输入服务器地址。", "server_declined": "你没有允许此服务器,因此没有向它发送任何内容。", "server_invalid": "此服务器地址无效。" }, + "desktop_browser_sign_in": { + "button": "使用浏览器登录…", + "waiting": "正在等待浏览器…", + "waiting_hint": "请在浏览器中完成登录。你的密码管理器可以在浏览器中使用。", + "cancel": "取消", + "or_here": "或在此登录", + "timed_out": "浏览器登录用时过长。请重试。", + "no_browser": "Bulwark 无法打开浏览器。请重试,或在此登录。", + "failed": "通过浏览器登录失败。请重试。", + "handoff_banner": "这台电脑上的一个程序为 Bulwark 应用请求了此次登录。此浏览器不会登录 Bulwark。如果不是你发起的,请关闭此标签页。", + "consent_title": "要登录 Bulwark 应用吗?", + "consent_description": "这台电脑上端口 {port} 的一个程序正在请求 {username} 的登录信息。只有在你刚刚点击了这里 Bulwark 应用中的“使用浏览器登录…”时才继续。否则可能有人想进入你的账户:请选择“暂不”。", + "consent_confirm": "登录应用", + "consent_cancel": "暂不", + "page_done": "你可以关闭此标签页并返回 Bulwark。", + "page_failed": "登录失败。请返回 Bulwark 并重试。", + "page_link_used": "此链接只能使用一次。请返回 Bulwark 重新开始。", + "page_title": "登录 {server}", + "page_intro": "此页面来自这台电脑上的 Bulwark 应用。你的密码只会发送到 {server}。", + "page_username": "电子邮件地址或用户名", + "page_password": "密码", + "page_code": "双重验证代码", + "page_submit": "登录", + "page_wrong_password": "服务器不接受此用户名和密码。", + "page_code_needed": "此账户还需要双重验证代码。请重新输入密码和代码。", + "page_no_answer": "Bulwark 无法登录。请检查服务器是否可访问,然后重试。", + "declined": "你在浏览器中拒绝了,因此没有登录。", + "too_many": "来自此网络的登录尝试过多。请等几分钟后重试。", + "page_declined": "没有交出任何内容。你可以关闭此标签页。" + }, "desktop_updates": { "title": "更新", "version_label": "Bulwark {version}", @@ -6966,6 +6998,51 @@ "copy_failed": "无法复制文件。", "reply_lost": "无法保留你输入的回复。请打开邮件并在其中回复。" }, + "desktopEdit": { + "edit": "编辑", + "edit_named": "编辑 {name}", + "opened": "{name} 已在其程序中打开。请在那里保存更改,之后会询问你如何处理这些更改。", + "offers_label": "已编辑的附件", + "changed_title": "{name} 已更改", + "changed_body": "你在其他程序中保存了更改。要如何处理?", + "reply": "附加到回复", + "new_message": "附加到新邮件", + "open_draft": "打开草稿", + "save_copy": "保存副本…", + "discard": "放弃编辑…", + "discard_title": "要放弃对 {name} 的编辑吗?", + "discard_detail": "编辑后的副本将被删除。原始附件不会改变。", + "discard_confirm": "放弃", + "cancel": "取消", + "update": "更新附件", + "no_app": "这台电脑上没有程序能打开此文件。", + "blocked_type": "此类文件可能作为程序运行,因此无法打开进行编辑。", + "blocked_policy": "你的组织不允许在此设备上保存附件。", + "removed_policy": "你的组织已不再允许此操作,因此 {name} 的编辑副本已被删除。", + "too_large": "此文件太大,无法打开进行编辑。", + "too_many": "打开编辑的文件太多。请先处理完其中一些。", + "failed": "无法打开文件进行编辑。", + "read_busy": "其他程序仍在使用 {name}。请在那里关闭后重试。", + "read_failed": "无法读取 {name} 的编辑副本。", + "send_stale_title": "{name} 有未包含在此邮件中的编辑", + "send_stale_detail": "如果现在发送,邮件将使用旧版本。你的编辑最多在这台电脑上保留 30 天,直到你决定如何处理。", + "send_stale_send": "仍然发送", + "send_stale_back": "返回", + "signout_title": "要退出登录并删除你的编辑吗?", + "signout_confirm": "退出登录", + "lock_note": "应用锁定期间,通过“编辑”打开的文件仍在其程序中保持打开,并且在这台电脑上仍可读取。解锁前无法打开新文件。", + "changed_title_hidden": "已保存编辑过的附件", + "last_chance": "30 天未使用。此副本将于明天删除。", + "held_named": "{names}:仍在其他程序中打开,因此副本暂时无法删除。在那里关闭后即会删除。", + "held_unnamed": "某个附件的副本仍在其他程序中打开,暂时无法删除。在那里关闭后即会删除。", + "removed_policy_held": "你的组织已不再允许此操作。{name} 仍在其他程序中打开,因此要在那里关闭后才能删除副本。", + "removed_policy_hidden": "你的组织已不再允许编辑附件,因此已移除一个编辑副本。", + "outside_note": "该程序自身的恢复文件和最近文件列表不在此应用可清除的范围内。", + "clear_title": "要删除编辑过的附件吗?", + "clear_confirm": "删除", + "signout_changed": "尚未附加或保存,现在将删除:{names}。", + "signout_held": "仍在其他程序中打开,因此要在那里关闭后才会删除:{names}。" + }, "desktopQuickCompose": { "label": "快速撰写", "menu": "快速撰写", diff --git a/public/sw.js b/public/sw.js index 62a4a7b62..47cde4b71 100644 --- a/public/sw.js +++ b/public/sw.js @@ -589,17 +589,40 @@ async function handlePush(event) { // message ids that passed it - spam filed into Junk never gets here. // - "jmap-state-change" (older servers): a bare EmailDelivery ping wrapped // as { changed: { [accountId]: {...} } }, fired for every ingested - // message including junk. The relay forwards a single account per push, - // so the first key is the one this notification is for. + // message including junk. Stalwart batches what changes between two + // pushes, so it can name the user's own account and a group's at once. const changed = payload && payload.changed && typeof payload.changed === "object" ? payload.changed : null; - const accountId = (payload && typeof payload.accountId === "string" && payload.accountId) + const named = (payload && typeof payload.accountId === "string" && payload.accountId) || (changed ? Object.keys(changed)[0] || "" : ""); const emailIds = payload && Array.isArray(payload.emailIds) ? payload.emailIds.filter((id) => typeof id === "string" && id) : []; + const withNewMail = emailIds.length > 0 || !changed + ? [] + : Object.keys(changed).filter((id) => changed[id] && typeof changed[id] === "object" && "EmailDelivery" in changed[id]); + // Side by side: each has its own preview deadline, and Safari wants the + // notifications within 30 s of the push. + await Promise.all((withNewMail.length > 0 ? withNewMail : [named]).map(async (accountId) => { + try { + await notifyAccount(accountId, emailIds, accountLabel); + } catch (_) { + // Nothing above may cost the user the alert, or a browser its quota for + // this site: see showWithoutAlert. + await self.registration.showNotification(accountLabel ? `New mail (${accountLabel})` : "New mail", { + body: "You have new mail", + tag: "bulwark-mail:" + (accountId || "default"), + icon: `${BASE_PATH}/api/pwa-icon/192`, + badge: `${BASE_PATH}/api/pwa-icon/192`, + data: { kind: "mail-list", accountId }, + }); + } + })); +} + +async function notifyAccount(accountId, emailIds, accountLabel) { // Never notify twice for the same message. A push can be redelivered (relay // retry, browser replay after coming back online) and, on the older // state-change path, a junk delivery wakes us while an unread message we @@ -613,6 +636,14 @@ async function handlePush(event) { return; } + // Email notifications off, or do not disturb on: a browser still wants a + // notification for the push, so one comes and goes with nothing on it. + if (!(await showPreference(accountLabel))) { + if (freshIds.length > 0) await writeNotifiedIds(accountId, notified.concat(freshIds)); + await showWithoutAlert(groupTag); + return; + } + // Best effort: ask the webmail to look up the email so we can build a useful // notification. With ids from the server we ask for exactly the newest of // them; otherwise the API falls back to the newest unread Inbox message. If @@ -625,6 +656,9 @@ async function handlePush(event) { if (!lockHidesContent) try { const query = new URLSearchParams(); if (accountId) query.set("accountId", accountId); + // The relay record's label is the login that registered. Account and message + // ids repeat across logins, and this tells them apart. + if (accountLabel) query.set("login", accountLabel); if (freshIds.length > 0) query.set("emailId", freshIds[freshIds.length - 1]); const qs = query.toString(); const previewUrl = `${BASE_PATH}/api/push/preview${qs ? `?${qs}` : ""}`; @@ -684,31 +718,36 @@ async function handlePush(event) { // into a single, self-updating notification instead of one toast per message // (Android renders one notification per unique tag, which is why 50 arrivals // used to stack 50 toasts). The newest message is the headline and a - // Gmail-style "+N more" line carries the rest, counted from the account's - // unread total. + // Gmail-style "+N more" line carries the rest: the messages this notification + // has announced and the user hasn't dismissed. Mail that was unread before + // doesn't count, or one new message in a full inbox would say "+40 more" and + // open the inbox instead of itself. + const announced = freshIds.length > 0 ? freshIds : (email ? [email.id] : []); + const ids = Array.from(new Set((await idsOnScreen(groupTag)).concat(announced))).slice(-NOTIFICATION_IDS_LIMIT); + const more = Math.max(ids.length - 1, 0); + const oneMessage = email ? { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login, ids } : null; let title; let body; - let data = { kind: "mail-list", accountId, slot }; + let data = { kind: "mail-list", accountId, slot, ids }; if (email && preview && preview.redacted) { // The organisation keeps sender and subject off notifications // (Organisation > Devices): the route sent neither. title = accountLabel ? `New mail (${accountLabel})` : "New mail"; - body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail"; - if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login }; + body = more > 0 ? `${ids.length} new messages` : "You have new mail"; + if (more === 0) data = oneMessage; } else if (email) { const sender = email.from && email.from[0]; const senderName = (sender && sender.name) || (sender && sender.email) || "New mail"; title = senderName + (accountLabel ? ` (${accountLabel})` : ""); body = email.subject || email.preview || "(no subject)"; - const more = unreadTotal > 1 ? unreadTotal - 1 : 0; if (more > 0) { - // Several unread: this is a group. Keep the newest as the headline, add - // the "+N more" count, and open the inbox (not one message) on click. + // Several new: this is a group. Keep the newest as the headline, add the + // "+N more" count, and open the inbox (not one message) on click. body += "\n" + (more === 1 ? "+1 more message" : `+${more} more messages`); } else { - // Exactly one unread: deep-link straight to that message on click. - data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login }; + // One new message: deep-link straight to it on click. + data = oneMessage; } } else { title = accountLabel ? `New mail (${accountLabel})` : "New mail"; @@ -733,12 +772,26 @@ async function handlePush(event) { silent: !audible, }); - const announced = freshIds.length > 0 ? freshIds : (email ? [email.id] : []); if (announced.length > 0) { await writeNotifiedIds(accountId, notified.concat(announced)); } } +const NOTIFICATION_IDS_LIMIT = 100; + +/** The messages the account's notification on screen stands for. */ +async function idsOnScreen(groupTag) { + try { + const shown = (await self.registration.getNotifications({ tag: groupTag }))[0]; + const data = shown && shown.data; + if (!data) return []; + if (Array.isArray(data.ids)) return data.ids.filter((id) => typeof id === "string"); + return typeof data.emailId === "string" ? [data.emailId] : []; + } catch (_) { + return []; + } +} + // How long the preview may take before the generic notification is shown. const PREVIEW_DEADLINE_MS = 10_000; @@ -752,24 +805,32 @@ const PREVIEW_DEADLINE_MS = 10_000; */ async function showWithoutAlert(groupTag) { const options = { - tag: groupTag, icon: `${BASE_PATH}/api/pwa-icon/192`, badge: `${BASE_PATH}/api/pwa-icon/192`, renotify: false, silent: true, }; + let existing = null; + try { + existing = (await self.registration.getNotifications({ tag: groupTag }))[0] || null; + } catch (_) { + // Can't tell: treated as none on screen. + } try { - const existing = (await self.registration.getNotifications({ tag: groupTag }))[0]; if (existing) { await showWithActions(existing.title, { ...options, + tag: groupTag, body: existing.body, data: existing.data, }); return; } - await self.registration.showNotification("New mail", { ...options, data: { kind: "mail-list" } }); - const shown = await self.registration.getNotifications({ tag: groupTag }); + // Under a tag of its own: a real notification for this account that comes in + // at the same moment must neither be replaced by this nor closed with it. + const emptyTag = `${groupTag}:nothing-new`; + await self.registration.showNotification("New mail", { ...options, tag: emptyTag, data: { kind: "mail-list" } }); + const shown = await self.registration.getNotifications({ tag: emptyTag }); for (const notification of shown) notification.close(); } catch (_) { // Nothing more a worker can do. @@ -852,6 +913,23 @@ async function soundPreference() { } } +/** + * The "Email notifications" switch and do not disturb. Each login has its own + * settings, so the page leaves one answer per login (the push's label names it). + * Nothing stored for it: show. + */ +async function showPreference(accountLabel) { + if (!accountLabel) return true; + try { + const cache = await caches.open(PUSH_STATE_CACHE); + const res = await cache.match(`${self.location.origin}${BASE_PATH}/__push-state/quiet-logins`); + const quiet = res ? await res.json() : null; + return !quiet || quiet[accountLabel] !== true; + } catch (_) { + return true; + } +} + /** True when another account alerted audibly less than QUIET_WINDOW_MS ago; otherwise records this one. */ async function withinQuietWindow(accountId) { const now = Date.now(); @@ -912,20 +990,73 @@ async function writeNotifiedIds(accountId, ids) { } } +/** What the page left here: lib/pwa-notify/worker-state.ts. */ +async function readRelayRegistrations() { + try { + const cache = await caches.open(PUSH_STATE_CACHE); + const res = await cache.match(`${self.location.origin}${BASE_PATH}/__push-state/relay-registrations`); + const stored = res ? await res.json() : null; + return stored && stored.entries && typeof stored.entries === "object" ? stored : null; + } catch (_) { + return null; + } +} + +/** No-third-party mode may have come on since the page left a relay here. Unknown counts as no. */ +async function mayCallRelays() { + try { + const res = await fetch(`${BASE_PATH}/api/config`, { cache: "no-store" }); + if (!res.ok) return false; + const config = await res.json(); + return config.noThirdPartyTraffic !== true; + } catch (_) { + return false; + } +} + +function applicationServerKeyOf(base64Url) { + const padded = base64Url + "=".repeat((4 - (base64Url.length % 4)) % 4); + const raw = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); + return Uint8Array.from(raw, (character) => character.charCodeAt(0)); +} + // The push service dropped or replaced the browser's subscription (expiry, // a key rotation, revoked permission). Re-subscribe with the same relay key -// unless the browser already did, then tell the open tabs: only a page has -// the login needed to register the new endpoint with the relay and the mail -// server (resyncWebPush in lib/web-push.ts). With no tab open, the next page -// load's re-sync picks the new subscription up. +// unless the browser already did. The mail server's subscription points at the +// relay record, not at the endpoint, so telling the relay is all it takes, and +// that needs no login: push is back with no tab open. Open tabs hear of it too, +// for what only a page can do (resyncWebPush in lib/web-push.ts). async function handlePushSubscriptionChange(event) { const old = event.oldSubscription; - if (!event.newSubscription && old && old.options) { - try { - await self.registration.pushManager.subscribe(old.options); - } catch (_) { - // Permission revoked or push service unreachable: the page's re-sync - // subscribes again itself when it can. + const registrations = await readRelayRegistrations(); + let subscription = event.newSubscription || null; + if (!subscription) { + const options = old && old.options + ? old.options + : registrations && registrations.applicationServerKey + ? { userVisibleOnly: true, applicationServerKey: applicationServerKeyOf(registrations.applicationServerKey) } + : null; + if (options) { + try { + subscription = await self.registration.pushManager.subscribe(options); + } catch (_) { + // Permission revoked or push service unreachable: the page's re-sync + // subscribes again itself when it can. + } + } + } + if (subscription && registrations && typeof subscription.toJSON === "function" && (await mayCallRelays())) { + const json = subscription.toJSON(); + for (const entry of Object.values(registrations.entries)) { + try { + await fetch(`${entry.relay}/api/push/register/web`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subscriptionId: entry.subscriptionId, subscription: json, accountLabel: entry.accountLabel }), + }); + } catch (_) { + // The page's re-sync registers it when it next runs. + } } } const allClients = await self.clients.matchAll({ @@ -973,7 +1104,7 @@ async function handleNotificationClick(event) { if (action === "reply") targetUrl = buildClickUrl(data, true); } - for (const client of allClients) { + for (const client of windowsForClick(allClients)) { // Reuse an existing tab whenever possible - users on desktop browsers // get annoyed when each notification opens a fresh window. if ("focus" in client) { @@ -1014,6 +1145,25 @@ async function handleNotificationClick(event) { } } +/** + * The windows a click may take over, the one in front first. Not a frame inside + * a page, and not a pop-out or a protocol page: navigating one of those would + * lose what it shows and leave the message in a window without the app around it. + */ +function windowsForClick(allClients) { + const score = (client) => (client.focused ? 0 : client.visibilityState === "visible" ? 1 : 2); + return allClients + .filter((client) => { + if (client.frameType && client.frameType !== "top-level") return false; + try { + return !/\/(popout|protocol)(\/|$)/.test(new URL(client.url).pathname); + } catch (_) { + return true; + } + }) + .sort((a, b) => score(a) - score(b)); +} + async function focusExistingWindowClient(sourceClientId, requireMailtoReady) { const entry = await findReusableWindowClientEntry(sourceClientId, requireMailtoReady); const client = entry && entry.client; diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index 088f3533a..70241e56e 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -118,6 +118,11 @@ export const LITE_API_STRING_ALLOWLIST = [ // the fragment. "/api/auth/handoff/create", "/api/auth/handoff/confirm", + // The desktop app's sign-in on an instance: no bridge in a browser, and IS_LITE guards. + "/api/auth/desktop-handoff/check", + "/api/auth/desktop-handoff/discard", + "/api/auth/desktop-handoff/park", + "/api/auth/desktop-handoff/redeem", "/api/auth/totp-token-exchange", "/api/auth/session", "/api/auth/verify", diff --git a/scripts/standalone-guard.mjs b/scripts/standalone-guard.mjs index 192cc94e8..80a268e1d 100644 --- a/scripts/standalone-guard.mjs +++ b/scripts/standalone-guard.mjs @@ -25,7 +25,7 @@ const TRACE_ALLOWED = new Set(['node_modules', '.next', 'public', 'locales', 'pa /** Never in a standalone tree, wherever it sits outside node_modules. */ const FORBIDDEN_DIRS = new Set(['data', 'local-data', 'repos']); -const FORBIDDEN_FILE = /^(\.env.*|plugin-signing\.key)$/; +const FORBIDDEN_FILE = /^(\.env.*|plugin-signing\.key)$/i; function toPosix(p) { return p.split(path.sep).join('/'); @@ -106,10 +106,13 @@ export function findForbidden(standaloneDir) { const visit = (dir) => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); - if (entry.isDirectory()) { + if (entry.isSymbolicLink()) { + // Not .next's or a package's, which aren't visited: Turbopack links those on purpose. + if (entry.name !== 'node_modules' && entry.name !== '.next') found.push(`${toPosix(path.relative(standaloneDir, full))} (link)`); + } else if (entry.isDirectory()) { // Packages may hold anything, and .next/server has routes named after data. if (entry.name === 'node_modules' || entry.name === '.next') continue; - if (FORBIDDEN_DIRS.has(entry.name)) found.push(toPosix(path.relative(standaloneDir, full)) + '/'); + if (FORBIDDEN_DIRS.has(entry.name.toLowerCase())) found.push(toPosix(path.relative(standaloneDir, full)) + '/'); else visit(full); } else if (FORBIDDEN_FILE.test(entry.name)) { found.push(toPosix(path.relative(standaloneDir, full))); diff --git a/stores/__tests__/auth-store-desktop-handoff.test.ts b/stores/__tests__/auth-store-desktop-handoff.test.ts new file mode 100644 index 000000000..a78c430b4 --- /dev/null +++ b/stores/__tests__/auth-store-desktop-handoff.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '@/lib/jmap/client'; +import { generateAccountId } from '@/lib/account-utils'; +import { useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; + +const SERVER = 'https://mail.example.com'; +const OPTIONS = { remember: true, rememberPassword: true, allowedServers: null, customServersAllowed: true }; + +/** + * What the desktop app becomes after a sign-in in the browser. A typed password stays a + * password account (#1432): its settings are there, and sign-out doesn't go to a provider. + */ +describe('loginWithDesktopHandoff', () => { + let redeemAnswer: unknown; + let redeemBody: Record; + + beforeEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + window.history.pushState({}, '', '/en/login'); + useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null }); + useAuthStore.setState({ isAuthenticated: false, isLoading: false, error: null, serverUrl: null, username: null, client: null, activeAccountId: null }); + vi.spyOn(JMAPClient.prototype, 'connect').mockResolvedValue(undefined as never); + vi.spyOn(JMAPClient.prototype, 'getUsername').mockReturnValue('session-name@example.com'); + vi.spyOn(JMAPClient.prototype, 'getSessionUsername').mockReturnValue('session-name@example.com'); + vi.spyOn(JMAPClient.prototype, 'getIdentities').mockResolvedValue([{ id: 'i', name: 'Alice', email: 'alice@example.com' }] as never); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes('/api/auth/desktop-handoff/redeem')) { + redeemBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify(redeemAnswer), { status: 200 }); + } + return new Response('{}', { status: 200 }); + })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const account = () => useAccountStore.getState().getActiveAccount(); + + it('tokens from a typed password are a password account, under the name typed', async () => { + redeemAnswer = { flow: 'tokens', server_url: SERVER, access_token: 'a', expires_in: 3600, remember: true, password_login: true, username: 'alice' }; + expect(await useAuthStore.getState().loginWithDesktopHandoff('c', 'v', OPTIONS)).toBe('ok'); + expect(account()).toMatchObject({ authMode: 'oauth', providerSession: false, username: 'alice' }); + // A short login name has the same id as the password sign-in, so what was kept for it comes back. + expect(account()?.id).toBe(generateAccountId('alice', SERVER)); + expect(account()?.id).not.toBe(generateAccountId('alice@example.com', SERVER)); + expect(useAuthStore.getState().username).toBe('alice'); + expect(account()?.serverIdentifiers?.length).toBeGreaterThan(0); + }); + + it('a provider\'s sign-in stays a provider session, under the address the server gives', async () => { + redeemAnswer = { flow: 'tokens', server_url: SERVER, access_token: 'a', expires_in: 3600, remember: true }; + expect(await useAuthStore.getState().loginWithDesktopHandoff('c', 'v', OPTIONS)).toBe('ok'); + expect(account()).toMatchObject({ authMode: 'oauth', providerSession: true, username: 'alice@example.com' }); + }); + + it('sends the device\'s rules with the code', async () => { + redeemAnswer = { flow: 'tokens', server_url: SERVER, access_token: 'a', expires_in: 3600, remember: true }; + await useAuthStore.getState().loginWithDesktopHandoff('c', 'v', { ...OPTIONS, allowedServers: ['https://a.example'], customServersAllowed: false }); + expect(redeemBody).toMatchObject({ code: 'c', code_verifier: 'v', allowed_servers: ['https://a.example'], custom_servers_allowed: false }); + }); +}); diff --git a/stores/__tests__/auth-store-push-signout.test.ts b/stores/__tests__/auth-store-push-signout.test.ts new file mode 100644 index 000000000..43aa7cc67 --- /dev/null +++ b/stores/__tests__/auth-store-push-signout.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const endWebPushForSignOut = vi.hoisted(() => vi.fn(async (_client: unknown) => {})); +const dropAllWebPushRegistrations = vi.hoisted(() => vi.fn(async () => {})); +vi.mock('@/lib/web-push', async (original) => ({ ...(await original()), endWebPushForSignOut, dropAllWebPushRegistrations })); + +import { setClientForTests, useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; + +function fakeClient() { + return { disconnect: vi.fn(), getAccountId: () => 'jmap-a' } as unknown as NonNullable['client']>; +} + +describe('signing out ends the login\'s push registration', () => { + beforeEach(() => { + endWebPushForSignOut.mockClear(); + vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 }))); + }); + + it('when the user signs out', async () => { + const client = fakeClient(); + useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false }); + await useAuthStore.getState().logout(); + expect(endWebPushForSignOut).toHaveBeenCalledWith(client); + }); + + it('when a wipe signs the login out', async () => { + const client = fakeClient(); + useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false }); + await useAuthStore.getState().logout({ endProviderSession: false }); + expect(endWebPushForSignOut).toHaveBeenCalledWith(client); + }); + + it('not when the session only ran out: it comes back with the next sign-in', async () => { + const client = fakeClient(); + useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false }); + await useAuthStore.getState().logout({ endProviderSession: false, keepLocalData: true }); + expect(endWebPushForSignOut).not.toHaveBeenCalled(); + }); + + it('removing a login ends its push while its client can still reach the server', async () => { + let accountId = 'jmap-b'; + const order: string[] = []; + const client = { + disconnect: vi.fn(() => { order.push('disconnect'); accountId = ''; }), + getAccountId: () => accountId, + } as unknown as Parameters[1]; + endWebPushForSignOut.mockImplementationOnce(async (seen: unknown) => { + await Promise.resolve(); + order.push(`push ended for ${(seen as { getAccountId: () => string }).getAccountId()}`); + }); + setClientForTests('login-b', client); + useAccountStore.setState({ accounts: [{ id: 'login-b', cookieSlot: 1, username: 'b', serverUrl: 'https://x' }] as never }); + useAuthStore.setState({ activeAccountId: 'login-a', isDemoMode: false }); + + useAuthStore.getState().removeAccount('login-b'); + await vi.waitFor(() => expect(order).toContain('disconnect')); + + expect(order).toEqual(['push ended for jmap-b', 'disconnect']); + }); + + it('a login the session threw out keeps its push for the next sign-in', async () => { + const client = fakeClient(); + setClientForTests('login-c', client as unknown as Parameters[1]); + useAccountStore.setState({ accounts: [{ id: 'login-c', cookieSlot: 1, username: 'c', serverUrl: 'https://x' }] as never }); + useAuthStore.setState({ activeAccountId: 'login-a', isDemoMode: false }); + + useAuthStore.getState().removeAccount('login-c', 'evicted'); + await vi.waitFor(() => expect(client.disconnect).toHaveBeenCalled()); + + expect(endWebPushForSignOut).not.toHaveBeenCalled(); + }); + + it('signs out even if the push code cannot be loaded', async () => { + const client = fakeClient(); + endWebPushForSignOut.mockRejectedValueOnce(new Error('chunk failed')); + useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: false }); + await useAuthStore.getState().logout(); + expect(useAuthStore.getState().client).toBeNull(); + }); + + it('signing out of everything also covers logins with no connection', async () => { + dropAllWebPushRegistrations.mockClear(); + useAuthStore.setState({ isDemoMode: false }); + await useAuthStore.getState().logoutAll(); + expect(dropAllWebPushRegistrations).toHaveBeenCalledTimes(1); + }); + + it('not in demo mode', async () => { + const client = fakeClient(); + useAuthStore.setState({ client, isAuthenticated: true, activeAccountId: null, isDemoMode: true }); + await useAuthStore.getState().logout(); + expect(endWebPushForSignOut).not.toHaveBeenCalled(); + useAuthStore.setState({ isDemoMode: false }); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 7953d72a2..bb662c80c 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -110,6 +110,11 @@ interface AuthState { loginWithToken: (serverUrl: string, token: string, rememberMe?: boolean) => Promise; loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string, serverId?: string) => Promise; loginWithServerSso: (code: string, state: string) => Promise; + /** + * The desktop app on an instance: the one-time code from its browser sign-in, with the + * verifier it kept. `blocked` is the device's own list of servers saying no. + */ + loginWithDesktopHandoff: (code: string, verifier: string, options: { remember: boolean; rememberPassword: boolean; allowedServers: string[] | null; customServersAllowed: boolean }) => Promise<'ok' | 'blocked' | 'failed'>; loginDemo: () => Promise; /** * Obtain a usable access token for `accountId`, or the active account. @@ -1625,6 +1630,129 @@ function notifySignedIn(state: { activeAccountId: string | null; username: strin void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' }); } +/** + * A provider's tokens are in, with the refresh token already in the slot's cookie: the + * rest of a sign-in. Server-side SSO, and the desktop app's sign-in on an instance. + */ +async function adoptProviderSession( + set: (state: Partial) => void, + get: () => AuthState, + session: { + serverUrl: string; + accessToken: string; + expiresIn: number; + remember: boolean | undefined; + slot: number; + /** + * Tokens from a typed password, not a provider's page: the account the user knows, under + * the name they typed. It has the same id as any other password sign-in with that name, so + * what its last sign-out kept comes back, and its password settings stay (#1432). + */ + typedUsername?: string; + }, +): Promise { + const { serverUrl: ssoServerUrl, accessToken: access_token, expiresIn: expires_in, remember, slot } = session; + const typed = session.typedUsername ? toAsciiEmail(session.typedUsername) : ''; + const accountStore = useAccountStore.getState(); + let boundAccountId: string | undefined; + const client = JMAPClient.withBearer(ssoServerUrl, access_token, typed, () => get().refreshAccessToken({ accountId: boundAccountId })); + await client.connect(); + + const jmapUsername = client.getUsername(); + const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), typed || jmapUsername); + // For SSO/OIDC, the JMAP session account name may be the + // preferred_username claim rather than the real email address. + // Prefer the email from the primary identity when available. + const username = typed || primaryIdentity?.email || jmapUsername; + initializeFeatureStores(client); + + const accountId = generateAccountId(username, ssoServerUrl); + boundAccountId = accountId; + + const prevAccountId = get().activeAccountId; + if (prevAccountId && prevAccountId !== accountId) { + snapshotAccount(prevAccountId); + clearAllStores(); + } + + holdClient(accountId, client); + bindClientStatusHandlers(client, set, get, accountId); + + accountStore.addAccount({ + label: primaryIdentity?.name || username, + serverUrl: ssoServerUrl, + username, + authMode: 'oauth', + rememberMe: remember !== false, + displayName: primaryIdentity?.name || username, + email: primaryIdentity?.email || username, + lastLoginAt: Date.now(), + isConnected: true, + hasError: false, + isDefault: accountStore.accounts.length === 0, + }); + // The refresh-token cookie was written to `slot` by /api/auth/sso/complete. + // Force the stored cookieSlot to match - see loginWithOAuth above for the + // re-add and concurrent-tab cases this guards against. + accountStore.updateAccount(accountId, { cookieSlot: slot, providerSession: !typed }); + if (typed) { + const serverIdentifiers = buildServerIdentifiers(client.getSessionUsername(), primaryIdentity?.email, ssoServerUrl); + if (serverIdentifiers.length > 0) accountStore.updateAccount(accountId, { serverIdentifiers }); + } + accountStore.setActiveAccount(accountId); + + await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), slot); + void syncAccountDisplayName(accountId, client, primaryIdentity?.name); + + set({ + isAuthenticated: true, + isLoading: false, + serverUrl: ssoServerUrl, + username, + client, + ...getClientRateLimitState(client), + identities, + primaryIdentity, + authMode: 'oauth', + accessToken: access_token, + tokenExpiresAt: Date.now() + expires_in * 1000, + connectionLost: false, + error: null, + activeAccountId: accountId, + }); + + import('@/stores/email-store').then(({ useEmailStore }) => { + useEmailStore.getState().prefetchInitialData(client).catch((err) => { + debug.error('Initial data prefetch failed:', err); + }); + }).catch(() => {}); + + scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId }), accountId); + + notifyParent('sso:auth-success', { username }); + + fetchConfig().then(cfg => { + if (!cfg.settingsSyncEnabled) return; + useSettingsStore.getState().loadFromServer(username, ssoServerUrl).finally(() => { + useSettingsStore.getState().enableSync(username, ssoServerUrl); + applyPreferredIdentity(accountId); + }); + }).catch(() => {}); + + notifySignedIn(get()); +} + +/** Within the sign-out's budget: an unreachable server doesn't hold it up. */ +function endPushForSignOut(signingOut: Iterable): Promise { + return Promise.race([ + (async () => { + const { endWebPushForSignOut } = await import('@/lib/web-push'); + for (const client of signingOut) await endWebPushForSignOut(client); + })().catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)), + ]); +} + function beforeLogoutHooks(accountId: string, options: SignOutCleanupOptions = {}): Promise { return Promise.race([ Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId, options)]).then(() => {}), @@ -1632,6 +1760,11 @@ function beforeLogoutHooks(accountId: string, options: SignOutCleanupOptions = { ]); } +/** Test helper: the store's own clients come from signing in. */ +export function setClientForTests(accountId: string, client: JMAPClient): void { + clients.set(accountId, client); +} + export const useAuthStore = create()( persist( (set, get) => ({ @@ -2291,88 +2424,7 @@ export const useAuthStore = create()( throw new Error('Server URL not configured'); } - let boundAccountId: string | undefined; - const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => get().refreshAccessToken({ accountId: boundAccountId })); - await client.connect(); - - const jmapUsername = client.getUsername(); - const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), jmapUsername); - // For SSO/OIDC, the JMAP session account name may be the - // preferred_username claim rather than the real email address. - // Prefer the email from the primary identity when available. - const username = primaryIdentity?.email || jmapUsername; - initializeFeatureStores(client); - - const accountId = generateAccountId(username, ssoServerUrl); - boundAccountId = accountId; - - const prevAccountId = get().activeAccountId; - if (prevAccountId && prevAccountId !== accountId) { - snapshotAccount(prevAccountId); - clearAllStores(); - } - - holdClient(accountId, client); - bindClientStatusHandlers(client, set, get, accountId); - - accountStore.addAccount({ - label: primaryIdentity?.name || username, - serverUrl: ssoServerUrl, - username, - authMode: 'oauth', - rememberMe: remember !== false, - displayName: primaryIdentity?.name || username, - email: primaryIdentity?.email || username, - lastLoginAt: Date.now(), - isConnected: true, - hasError: false, - isDefault: accountStore.accounts.length === 0, - }); - // The refresh-token cookie was written to `slot` by /api/auth/sso/complete. - // Force the stored cookieSlot to match - see loginWithOAuth above for the - // re-add and concurrent-tab cases this guards against. - accountStore.updateAccount(accountId, { cookieSlot: slot, providerSession: true }); - accountStore.setActiveAccount(accountId); - - await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), slot); - void syncAccountDisplayName(accountId, client, primaryIdentity?.name); - - set({ - isAuthenticated: true, - isLoading: false, - serverUrl: ssoServerUrl, - username, - client, - ...getClientRateLimitState(client), - identities, - primaryIdentity, - authMode: 'oauth', - accessToken: access_token, - tokenExpiresAt: Date.now() + expires_in * 1000, - connectionLost: false, - error: null, - activeAccountId: accountId, - }); - - import('@/stores/email-store').then(({ useEmailStore }) => { - useEmailStore.getState().prefetchInitialData(client).catch((err) => { - debug.error('Initial data prefetch failed:', err); - }); - }).catch(() => {}); - - scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId }), accountId); - - notifyParent('sso:auth-success', { username }); - - fetchConfig().then(cfg => { - if (!cfg.settingsSyncEnabled) return; - useSettingsStore.getState().loadFromServer(username, ssoServerUrl).finally(() => { - useSettingsStore.getState().enableSync(username, ssoServerUrl); - applyPreferredIdentity(accountId); - }); - }).catch(() => {}); - - notifySignedIn(get()); + await adoptProviderSession(set, get, { serverUrl: ssoServerUrl, accessToken: access_token, expiresIn: expires_in, remember, slot }); return true; } catch (error) { debug.error('Server SSO login error:', error); @@ -2390,6 +2442,49 @@ export const useAuthStore = create()( } }, + loginWithDesktopHandoff: async (code, verifier, { remember, rememberPassword, allowedServers, customServersAllowed }) => { + if (IS_LITE) return 'failed'; + set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null }); + try { + const slot = useAccountStore.getState().getNextCookieSlot(); + const res = await apiFetch('/api/auth/desktop-handoff/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, code_verifier: verifier, slot, remember, allowed_servers: allowedServers, custom_servers_allowed: customServersAllowed }), + }).then((answer) => noteSessionAnswer(answer, slot)); + if (!res.ok) { + const refusal = (await res.json().catch(() => null)) as { error?: string } | null; + set({ isLoading: false, error: null }); + return refusal?.error === 'blocked_by_policy' ? 'blocked' : 'failed'; + } + const parked = await res.json(); + if (parked.flow === 'password') { + // A password the browser checked a moment ago, down the same path as a typed one. + return (await get().login(parked.server_url, parked.username, parked.password, undefined, rememberPassword)) ? 'ok' : 'failed'; + } + await adoptProviderSession(set, get, { + serverUrl: parked.server_url, + accessToken: parked.access_token, + expiresIn: parked.expires_in, + remember: parked.remember, + slot, + ...(parked.password_login ? { typedUsername: parked.username } : {}), + }); + return 'ok'; + } catch (error) { + debug.error('Desktop hand-off login error:', error); + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'generic', + isAuthenticated: false, + isRateLimited: false, + rateLimitUntil: null, + client: null, + }); + return 'failed'; + } + }, + refreshAccessToken: async (options) => { // Every multi-account client and timer passes its own account: renewing // the active slot on a background account's behalf would hand that @@ -2541,6 +2636,8 @@ export const useAuthStore = create()( if (userInitiated && !(await passSignOutGates(accountId ?? 'all'))) return; await beforeLogoutHooks(accountId ?? 'all', { keepLocalStore: keepLocalData }); + // A session that only ran out comes back with the next sign-in, push included. + if (state.client && !wasDemoMode && !keepLocalData) await endPushForSignOut([state.client]); // From here on nothing may renew this slot's session; the cleanup // below reopens it once the credentials are gone. @@ -2690,16 +2787,19 @@ export const useAuthStore = create()( if (!account) return; const slot = account.cookieSlot ?? 0; const wasOAuth = account.authMode === 'oauth'; - - // Started first: the cleanup marks the slot closing right away, so a - // renewal in flight cannot bring the session back. - void clearSlotCredentials(slot, wasOAuth); - clearRefreshTimer(accountId); const client = clients.get(accountId); - if (client) { - forgetCalendarSubscriptions(client); - try { client.disconnect(); } catch { /* noop */ } - } + // The slot is closing from here on: a renewal in flight cannot bring the + // session back. + closingSlots.add(slot); + clearRefreshTimer(accountId); + if (client) forgetCalendarSubscriptions(client); + // The login's push goes first: a client that disconnected has no account + // id and no API left to reach the server with. + const pushEnded = client && reason === 'explicit' ? endPushForSignOut([client]) : Promise.resolve(); + void pushEnded.finally(() => { + try { client?.disconnect(); } catch { /* noop */ } + return clearSlotCredentials(slot, wasOAuth); + }); clients.delete(accountId); evictAccount(accountId); forgetAccountRows(accountId, get().client); @@ -2717,6 +2817,15 @@ export const useAuthStore = create()( for (const account of useAccountStore.getState().accounts) noteRefusedLogin(account.id, true, 'session'); } await beforeLogoutHooks('all', { keepLocalStore: keepLocalData }); + if (!get().isDemoMode && !keepLocalData) { + // Each connected login first, on a budget that one unreachable server can + // use up. What's left, and whoever had no connection, only needs the relay. + await endPushForSignOut(clients.values()); + await Promise.race([ + import('@/lib/web-push').then((push) => push.dropAllWebPushRegistrations()).catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)), + ]); + } const accountStore = useAccountStore.getState(); const allAccounts = [...accountStore.accounts]; diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index cf37977ea..3216d301b 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -42,6 +42,8 @@ export interface ProReplyContext { htmlBody?: string; receivedAt?: string; accountId?: string; + /** The original carries the phishing mark. */ + phishingMarked?: boolean; attachments?: Array<{ blobId: string; name?: string; type: string; size: number; cid?: string; disposition?: string; diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 440c3a190..1d771cb5f 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -2276,9 +2276,8 @@ if (typeof window !== 'undefined') { applyInterfaceLayout(store.interfaceLayout, store.density); // The service worker shows push notifications without this store, so it - // reads the sound preference from its own Cache store (#1289). - // Do not disturb can't keep a push from showing (browsers require one per - // push), but it keeps it silent. + // reads the sound preference from its own Cache store (#1289). Whether they + // show at all goes per login (NotificationActionsHost). const pushSound = (s: SettingsState) => s.emailNotificationsEnabled && s.emailNotificationSound && !s.doNotDisturb; let lastPushSound = pushSound(store); void writePushPreferences({ sound: lastPushSound });