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/__tests__/marketplace-install-version.test.tsx b/app/(main)/admin/__tests__/marketplace-install-version.test.tsx index b8efa0cef..4377ec655 100644 --- a/app/(main)/admin/__tests__/marketplace-install-version.test.tsx +++ b/app/(main)/admin/__tests__/marketplace-install-version.test.tsx @@ -49,6 +49,36 @@ describe('installVersion', () => { expect(calls).toHaveLength(1); }); + it('says what an update adds, and repeats with what the admin saw', async () => { + responses = [json({ error: 'adds', permissionsAdded: ['keystore'] }, 409), json({ warnings: [] })]; + const asked: string[] = []; + const result = await installVersion(BODY, (q) => { asked.push(`${q.title} ${q.message}`); return true; }); + expect(asked[0]).toContain('new permissions'); + expect(asked[0]).toContain('keystore'); + expect(calls.map((c) => c.body.acceptPermissions)).toEqual([undefined, ['keystore']]); + expect(calls.map((c) => c.body.force), 'not the same as forcing it').toEqual([undefined, undefined]); + expect(result.ok).toBe(true); + }); + + it('does not update past new permissions the admin declined', async () => { + responses = [json({ error: 'adds', permissionsAdded: ['keystore'] }, 409)]; + expect(await installVersion(BODY, () => false)).toEqual({ ok: false, error: 'Not installed.' }); + expect(calls).toHaveLength(1); + }); + + it('asks about both when a withdrawn version also adds permissions, and keeps both answers', async () => { + responses = [ + json({ error: 'withdrawn', yanked: { reason: null } }, 409), + json({ error: 'adds', permissionsAdded: ['keystore'] }, 409), + json({ warnings: [] }), + ]; + const asked: string[] = []; + const result = await installVersion(BODY, (q) => { asked.push(q.title); return true; }); + expect(asked).toHaveLength(2); + expect(calls.at(-1)!.body).toMatchObject({ force: true, acceptPermissions: ['keystore'] }); + expect(result.ok).toBe(true); + }); + it('shows any other 409 as the error it is', async () => { responses = [json({ error: 'The configuration is read-only' }, 409)]; const result = await installVersion(BODY, () => true); diff --git a/app/(main)/admin/_pages/marketplace-detail.tsx b/app/(main)/admin/_pages/marketplace-detail.tsx index fdb7b54dd..a55a8ffcb 100644 --- a/app/(main)/admin/_pages/marketplace-detail.tsx +++ b/app/(main)/admin/_pages/marketplace-detail.tsx @@ -28,6 +28,7 @@ import { apiFetch } from '@/lib/browser-navigation'; import { compareVersions, isVersionSatisfied } from '@/lib/version-compare'; import type { MarketplaceListing } from '@/lib/admin/marketplace-directory'; import { adminPath } from '@/lib/admin/admin-nav'; +import { declaredPermissions, HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text'; import { cn } from '@/lib/utils'; import { SettingsSection } from '@/components/settings/settings-section'; import { Button } from '@/components/ui/button'; @@ -97,6 +98,8 @@ interface PreviewData { requestedVersionMissing: string | null; bundle: { manifest: Record | null; + /** What this version asks for that the installed one doesn't. */ + addedPermissions?: string[]; source: { name: string; content: string; truncated: boolean } | null; size: number; error: string | null; @@ -307,8 +310,10 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) { const bundle = data.bundle; const preview = data.preview; const isPlugin = ext.type === 'plugin'; - const manifestPerms = (bundle.manifest?.permissions as string[] | undefined) || preview && ext.versions.find((v) => v.version === preview.version)?.permissions || ext.permissions || []; + // Optional permissions are granted like the others, so they count here too. + const manifestPerms = bundle.manifest ? declaredPermissions(bundle.manifest) : (preview && ext.versions.find((v) => v.version === preview.version)?.permissions) || ext.permissions || []; const permissionText = new Map(ext.permissionDetails.map((p) => [p.id, p])); + const addedPermissions = new Set(bundle.addedPermissions ?? []); const frameOrigins = (bundle.manifest?.frameOrigins as string[] | undefined) || []; const settingsSchema = bundle.manifest?.settingsSchema as Record | undefined; @@ -705,7 +710,7 @@ export function MarketplaceDetailPage({ slug }: { slug: string }) {
    {manifestPerms.map(perm => { const text = permissionText.get(perm); - const risky = text?.highRisk || RISKY_PERMISSIONS.has(perm); + const risky = text?.highRisk || RISKY_PERMISSIONS.has(perm) || HIGH_RISK_PERMISSIONS.has(perm); return (
  • {text && {text.title}} {perm} + {addedPermissions.has(perm) && New in this version}
{text?.body &&

{text.body}

} diff --git a/app/(main)/admin/_pages/marketplace.tsx b/app/(main)/admin/_pages/marketplace.tsx index 25385f963..d0cd014ee 100644 --- a/app/(main)/admin/_pages/marketplace.tsx +++ b/app/(main)/admin/_pages/marketplace.tsx @@ -45,6 +45,7 @@ interface InstalledEntry { interface UpdatesResult { installed: InstalledEntry[]; updates: Array<{ + addedPermissions?: string[]; slug: string; currentVersion: string; version: string; channel: 'stable' | 'beta'; publishedAt: string | null; changelog: string | null; notes?: ChangelogFlags; changelogs?: ChangelogEntry[]; }>; @@ -95,7 +96,9 @@ const pillClass = (active: boolean) => cn( * Installs one version of an extension. A version the publisher withdrew, * or one older than the installed version, comes back as 409; the admin is * asked (in the page's ConfirmDialog), and the install is repeated with - * `force` if they insist. Returns the error text to show, or null. + * `force` if they insist. An update that asks for more permissions comes back + * the same way, and is repeated with the ones the admin saw. "Update all" goes + * through here too, so it asks about each. Returns the error text to show, or null. */ export interface InstallQuestion { title: string; @@ -108,28 +111,39 @@ export async function installVersion( body: { slug: string; version: string; type: 'plugin' | 'theme' }, confirm: (question: InstallQuestion) => boolean | Promise, ): Promise<{ ok: true; warnings: string[]; signature: string | null; updated: boolean } | { ok: false; error: string }> { - const send = (force: boolean) => apiFetch('/api/admin/marketplace', { + let force = false; + let acceptPermissions: string[] | undefined; + const send = () => apiFetch('/api/admin/marketplace', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(force ? { ...body, force: true } : body), + body: JSON.stringify({ ...body, ...(force ? { force: true } : {}), ...(acceptPermissions ? { acceptPermissions } : {}) }), }); try { - let res = await send(false); - if (res.status === 409) { + let res = await send(); + // At most one of each question: withdrawn or older, then permissions. + for (let asked = 0; res.status === 409 && asked < 3; asked += 1) { const data = await res.json().catch(() => ({})); - if (data.yanked) { + if (Array.isArray(data.permissionsAdded) && !acceptPermissions) { + const names = (data.permissionsAdded as string[]).join(', '); + const message = `${body.slug} ${body.version} asks for new permissions that the installed version doesn't have: ${names}.`; + if (!(await confirm({ title: 'Allow the new permissions?', message, confirmText: 'Update', cancelText: 'Cancel' }))) { + return { ok: false, error: 'Not installed.' }; + } + acceptPermissions = data.permissionsAdded; + } else if (data.yanked && !force) { if (!(await confirm({ title: 'Install it anyway?', message: data.error, confirmText: 'Install', cancelText: 'Cancel' }))) { return { ok: false, error: 'Not installed.' }; } - res = await send(true); - } else if (data.downgrade) { + force = true; + } else if (data.downgrade && !force) { if (!(await confirm({ title: 'Install the older version anyway?', message: data.error, confirmText: 'Install', cancelText: 'Cancel' }))) { return { ok: false, error: 'Not installed.' }; } - res = await send(true); + force = true; } else { return { ok: false, error: data.error || 'Installation failed' }; } + res = await send(); } const data = await res.json().catch(() => ({})); if (!res.ok) return { ok: false, error: data.error || 'Installation failed' }; @@ -463,6 +477,11 @@ function UpdatesSection({ {u.channel === 'beta' && beta} + {(u.addedPermissions?.length ?? 0) > 0 && ( +

+ New permissions: {u.addedPermissions!.join(', ')} +

+ )} {withNotes.length > 0 ? (
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/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index 91fb8509a..6f405832c 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -23,12 +23,22 @@ import { type DirectoryDetail, type DirectoryVersion, } from '@/lib/admin/marketplace-directory'; -import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; +import { IMPLICIT_PERMISSIONS, MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; +import { declaredPermissions, HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text'; +import { getPluginRegistry } from '@/lib/admin/plugin-registry'; import { productName } from '@/lib/product-name'; import { withAppName } from '@/lib/settings-search'; const MAX_PREVIEW_SOURCE_LEN = 100_000; +/** For a directory that predates it. It only lists what it knows about, and admin pages are in English. */ +const LOCAL_PERMISSION_TEXT: Record = { + keystore: { + title: 'Use certificates on this computer', + body: 'Ask to sign and decrypt messages with certificates on this computer or a smart card. The app asks each user first. The plugin never gets the private key, but while they allow it, it can sign in their name and read what it decrypts.', + }, +}; + /** A version row as the detail page renders it. */ function versionRow(v: DirectoryVersion) { return { @@ -176,6 +186,17 @@ export async function GET( const state = installedStateOf(installed, slug, type); const history = await historyFor(type, slug); const appName = productName(configManager.get('appName')); + // What the bundle itself declares, optional permissions too: the directory's list can leave some out. + const declared = type === 'plugin' ? declaredPermissions(manifest) : []; + const listed = new Set((detail.permissions ?? []).map((p) => p.id)); + const unlisted = declared.filter((id) => !listed.has(id) && LOCAL_PERMISSION_TEXT[id]).map((id) => ({ + id, + ...LOCAL_PERMISSION_TEXT[id], + highRisk: HIGH_RISK_PERMISSIONS.has(id), + })); + const installedPlugin = state.installed && type === 'plugin' ? (await getPluginRegistry()).plugins.find((p) => p.id === slug) : null; + const implicit = new Set(IMPLICIT_PERMISSIONS); + const addedPermissions = installedPlugin ? declared.filter((p) => !installedPlugin.permissions?.includes(p) && !implicit.has(p)) : []; return NextResponse.json( { @@ -189,7 +210,7 @@ export async function GET( themePreviews: detail.themePreviews ?? [], advisories: detail.advisories ?? [], // The directory ships the webmail's permission texts unfilled. - permissionDetails: (detail.permissions ?? []).map((p) => withAppName(p, appName) as typeof p), + permissionDetails: [...(detail.permissions ?? []), ...unlisted].map((p) => withAppName(p, appName) as typeof p), source: detail.source, createdAt: detail.createdAt ?? null, updatedAt: detail.updatedAt ?? null, @@ -202,6 +223,7 @@ export async function GET( requestedVersionMissing: requested && !requestedRow ? requested : null, bundle: { manifest, + addedPermissions, source: sourcePreview, size: bundleSize, error: bundleError, diff --git a/app/api/admin/marketplace/__tests__/preview.test.ts b/app/api/admin/marketplace/__tests__/preview.test.ts index 579aff6e5..a10c43c47 100644 --- a/app/api/admin/marketplace/__tests__/preview.test.ts +++ b/app/api/admin/marketplace/__tests__/preview.test.ts @@ -19,7 +19,7 @@ vi.mock('@/lib/admin/config-manager', () => ({ })); vi.mock('@/lib/admin/plugin-registry', () => ({ getPluginRegistry: vi.fn(async () => ({ - plugins: [{ id: 'pretty-dates', name: 'Pretty Dates', version: '1.1.0', enabled: true, source: { kind: 'marketplace', slug: 'pretty-dates', directoryUrl: 'https://extensions.test' } }], + plugins: [{ id: 'pretty-dates', name: 'Pretty Dates', version: '1.1.0', permissions: ['email:read'], enabled: true, source: { kind: 'marketplace', slug: 'pretty-dates', directoryUrl: 'https://extensions.test' } }], })), getThemeRegistry: vi.fn(async () => ({ themes: [] })), })); @@ -127,3 +127,43 @@ describe('GET /api/admin/marketplace/[slug]', () => { expect((await preview('?version=..%2F..%2Fetc')).status).toBe(400); }); }); + +describe('permissions in the preview', () => { + async function previewOf(manifest: Record) { + const zip = new JSZip(); + zip.file('manifest.json', JSON.stringify({ id: 'pretty-dates', name: 'Pretty Dates', version: '1.2.0', author: 'x', description: 'd', type: 'hook', entrypoint: 'index.js', ...manifest })); + zip.file('index.js', '// x'); + const bytes = (await zip.generateAsync({ type: 'uint8array' })).slice().buffer as ArrayBuffer; + const versions = [v('1.2.0')]; + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + const u = new URL(String(url)); + if (u.pathname === '/api/v2/extensions/pretty-dates') { + return Response.json({ data: { ...summary, latest: versions[0], newest: versions[0], longDescription: 'long', versions, permissions: [{ id: 'crypto:full', title: 'Full crypto', body: 'b', highRisk: true }], screenshots: [], themePreviews: [], advisories: [] } }); + } + if (u.pathname.endsWith('/versions')) return Response.json({ data: versions }); + if (u.pathname.endsWith('/bundle')) return new Response(bytes); + return Response.json({ data: versions[0] }); + })); + const res = await GET(new NextRequest('http://localhost/api/admin/marketplace/pretty-dates'), { params: Promise.resolve({ slug: 'pretty-dates' }) }); + return res.json(); + } + + it('names an optional keystore with its own text and as high risk, even if the directory does not', async () => { + const body = await previewOf({ tier: 'privileged', permissions: ['crypto:full'], optionalPermissions: ['keystore'] }); + const entry = body.extension.permissionDetails.find((p: { id: string }) => p.id === 'keystore'); + expect(entry).toMatchObject({ highRisk: true }); + expect(entry.title).toMatch(/certificates/i); + expect(body.extension.permissionDetails.filter((p: { id: string }) => p.id === 'crypto:full')).toHaveLength(1); + }); + + it('says what this version adds to the installed one', async () => { + const body = await previewOf({ tier: 'privileged', permissions: ['crypto:full', 'email:read'], optionalPermissions: ['keystore'] }); + expect(body.bundle.addedPermissions).toEqual(['crypto:full', 'keystore']); + }); + + it('adds nothing for an untrusted plugin, which can not get it', async () => { + const body = await previewOf({ permissions: ['email:read'], optionalPermissions: ['keystore'] }); + expect(body.extension.permissionDetails.map((p: { id: string }) => p.id)).not.toContain('keystore'); + expect(body.bundle.addedPermissions).toEqual([]); + }); +}); diff --git a/app/api/admin/marketplace/__tests__/route.test.ts b/app/api/admin/marketplace/__tests__/route.test.ts index 4caa86fbe..5960b280b 100644 --- a/app/api/admin/marketplace/__tests__/route.test.ts +++ b/app/api/admin/marketplace/__tests__/route.test.ts @@ -215,3 +215,49 @@ describe('POST /api/admin/marketplace', () => { expect(registry.get('pgp-plugin')?.version).toBe('2.0.0'); }); }); + +describe('an update that asks for more than the installed version', () => { + const INSTALLED = { id: 'pretty-dates', name: 'Pretty Dates', version: '0.9.0', permissions: ['email:read'], enabled: true }; + + it('is refused until someone has seen what it adds, and installs nothing meanwhile', async () => { + registry.set('pretty-dates', { ...INSTALLED }); + directory(await bundle({ ...MANIFEST, id: 'pretty-dates', permissions: ['email:read', 'email:send'] })); + const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' }); + expect(res.status).toBe(409); + expect((await res.json()).permissionsAdded).toEqual(['email:send']); + expect(registry.get('pretty-dates')!.version).toBe('0.9.0'); + }); + + it('counts an optional permission, since it is granted like any other', async () => { + registry.set('pretty-dates', { ...INSTALLED }); + directory(await bundle({ ...MANIFEST, id: 'pretty-dates', optionalPermissions: ['email:send'] })); + const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' }); + expect(res.status).toBe(409); + expect((await res.json()).permissionsAdded).toEqual(['email:send']); + }); + + it('forcing it is about something else, and does not get past this', async () => { + registry.set('pretty-dates', { ...INSTALLED }); + directory(await bundle({ ...MANIFEST, id: 'pretty-dates', permissions: ['email:read', 'email:send'] })); + expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin', force: true })).status).toBe(409); + }); + + it('goes through when what was accepted covers it, and only then', async () => { + registry.set('pretty-dates', { ...INSTALLED }); + directory(await bundle({ ...MANIFEST, id: 'pretty-dates', permissions: ['email:read', 'email:send', 'email:raw-send'] })); + expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin', acceptPermissions: ['email:send'] })).status).toBe(409); + expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin', acceptPermissions: 'email:send,email:raw-send' })).status).toBe(409); + const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin', acceptPermissions: ['email:send', 'email:raw-send'] }); + expect(res.status).toBe(200); + expect(registry.get('pretty-dates')!.permissions).toEqual(['email:read', 'email:send', 'email:raw-send']); + }); + + it('asks nothing when nothing is added, or on a first install', async () => { + registry.set('pretty-dates', { ...INSTALLED }); + directory(await bundle({ ...MANIFEST, id: 'pretty-dates' })); + expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' })).status).toBe(200); + registry.clear(); + directory(await bundle({ ...MANIFEST, id: 'pretty-dates', permissions: ['email:read', 'email:send'] })); + expect((await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' })).status).toBe(200); + }); +}); diff --git a/app/api/admin/marketplace/__tests__/updates.test.ts b/app/api/admin/marketplace/__tests__/updates.test.ts index 000c525cd..4dab78eb2 100644 --- a/app/api/admin/marketplace/__tests__/updates.test.ts +++ b/app/api/admin/marketplace/__tests__/updates.test.ts @@ -17,7 +17,7 @@ vi.mock('@/lib/admin/config-manager', () => ({ vi.mock('@/lib/admin/plugin-registry', () => ({ getPluginRegistry: vi.fn(async () => ({ plugins: [ - { id: 'quick-reply', name: 'Quick Reply', version: '1.2.0', enabled: true, source: { kind: 'marketplace', slug: 'quick-reply', directoryUrl: 'https://extensions.test' } }, + { id: 'quick-reply', name: 'Quick Reply', version: '1.2.0', permissions: ['email:read'], enabled: true, source: { kind: 'marketplace', slug: 'quick-reply', directoryUrl: 'https://extensions.test' } }, { id: 'house-plugin', name: 'House', version: '0.1.0', enabled: true, source: { kind: 'upload' } }, // Installed before the source was recorded: still asked about. { id: 'old-install', name: 'Old', version: '3.0.0', enabled: false }, @@ -34,6 +34,21 @@ import { GET } from '../updates/route'; afterEach(() => vi.unstubAllGlobals()); describe('GET /api/admin/marketplace/updates', () => { + it('says which permissions each update adds', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ + data: { + updates: [ + { slug: 'quick-reply', currentVersion: '1.2.0', version: '1.3.0', channel: 'stable', changelog: null, permissions: ['email:read', 'keystore'] }, + { slug: 'old-install', currentVersion: '3.0.0', version: '3.1.0', channel: 'stable', changelog: null }, + ], + yanked: [], deprecated: [], suspended: [], advisories: [], unknown: [], + }, + }))); + const body = await (await GET(new NextRequest('http://localhost/api/admin/marketplace/updates'))).json(); + expect(body.updates[0].addedPermissions).toEqual(['keystore']); + expect(body.updates[1].addedPermissions).toEqual([]); + }); + it('asks the directory about the marketplace installs only, on the configured channel', async () => { let posted: { url: string; body: unknown } | null = null; vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => { diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 103f59a9f..245e7fa29 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -42,7 +42,7 @@ import { type DirectorySummary, type DirectoryVersion, } from '@/lib/admin/marketplace-directory'; -import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types'; +import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, IMPLICIT_PERMISSIONS } from '@/lib/plugin-types'; import { createHash } from 'node:crypto'; import { devicePolicyPluginRefusal } from '@/lib/desktop/server-policy'; @@ -116,7 +116,7 @@ export async function POST(request: NextRequest) { } const ip = getClientIP(request); - const { slug, version, type, force } = await request.json(); + const { slug, version, type, force, acceptPermissions } = await request.json(); if (!slug || !version || !type) { return NextResponse.json({ error: 'Missing slug, version, or type' }, { status: 400 }); @@ -340,6 +340,20 @@ export async function POST(request: NextRequest) { const existingPlugin = await getPlugin(slug); const isUpdate = existingPlugin !== null; + // An update must not widen what a plugin may do with nobody seeing it. `force` is for + // withdrawn and older versions, and says nothing about this. + const implicit = new Set(IMPLICIT_PERMISSIONS); + const permissionsAdded = isUpdate + ? manifest.permissions.filter((p) => !existingPlugin.permissions?.includes(p) && !implicit.has(p)) + : []; + const accepted = Array.isArray(acceptPermissions) ? acceptPermissions : []; + if (permissionsAdded.some((p) => !accepted.includes(p))) { + return NextResponse.json( + { error: `Version ${version} asks for new permissions: ${permissionsAdded.join(', ')}.`, permissionsAdded }, + { status: 409 }, + ); + } + const plugin: ServerPlugin = { id: slug, name: manifest.name, @@ -382,6 +396,7 @@ export async function POST(request: NextRequest) { httpOrigins: plugin.httpOrigins ?? [], apiPostPaths: plugin.apiPostPaths ?? [], signature: signature.status, + ...(permissionsAdded.length > 0 ? { permissionsAdded } : {}), ...(meta.yanked ? { yanked: true } : {}), ...(isUpdate ? { previousVersion: existingPlugin.version } : {}), }, diff --git a/app/api/admin/marketplace/updates/route.ts b/app/api/admin/marketplace/updates/route.ts index e32bf5162..4dcf24174 100644 --- a/app/api/admin/marketplace/updates/route.ts +++ b/app/api/admin/marketplace/updates/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAdminAuth } from '@/lib/admin/session'; import { logger } from '@/lib/logger'; +import { getPluginRegistry } from '@/lib/admin/plugin-registry'; +import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types'; import { APP_VERSION, DirectoryError, @@ -19,7 +21,8 @@ import { export interface UpdatesAnswer { /** `changelogs` is missing from directories older than 2026-10-30; `changelog` is then the offered version's alone. */ - updates: Array; + /** `addedPermissions` is what the directory lists against what's installed; the install itself reads the bundle. */ + updates: Array; yanked: Array<{ slug: string; version: string; reason: string | null; at: string }>; deprecated: Array<{ slug: string; message: string | null; replacement: string | null }>; suspended: string[]; @@ -66,6 +69,13 @@ export async function GET(request: NextRequest) { body: JSON.stringify({ appVersion: APP_VERSION || undefined, channel, installed: ask }), }); answer = { ...EMPTY, ...res.data }; + const { plugins } = await getPluginRegistry(); + const held = new Map(plugins.map((p) => [p.id, new Set(p.permissions ?? [])])); + const implicit = new Set(IMPLICIT_PERMISSIONS); + answer.updates = answer.updates.map((u) => ({ + ...u, + addedPermissions: (u.permissions ?? []).filter((p) => !implicit.has(p) && !(held.get(u.slug)?.has(p) ?? true)), + })); } catch (err) { // The installed list is still useful without the directory; say what // went wrong instead of failing the whole view. 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/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index 9a806ccbc..18f63f935 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -117,8 +117,10 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {

What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything), - with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not - against someone using the unlocked browser or app. The limits above can only lower what people pick: where several + with calendar, contacts and files, encrypted on the device. In a browser the key sits in the same profile, so this doesn't stop someone + who copies its files: use full-disk encryption. In the desktop app the key also takes that person's login on + that computer. Neither stops someone using the unlocked browser or app. An app lock adds a PIN, which someone with + a copy can guess at their own speed, so ask for a long one. The limits above can only lower what people pick: where several scopes apply, the lowest value counts, so a scope can't raise what another one lowered. The app holds itself to them when it fetches the policy; the server can't check what is on a device. A sign-out someone asks for deletes everything. One that nobody asked for hides it, as the switch above says. With several accounts signed in, each account follows its own settings. 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/offline/__tests__/key-protection-notes.test.tsx b/components/offline/__tests__/key-protection-notes.test.tsx new file mode 100644 index 000000000..53ac50a25 --- /dev/null +++ b/components/offline/__tests__/key-protection-notes.test.tsx @@ -0,0 +1,50 @@ +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useOfflineStore } from '@/lib/offline/state'; +import { KeyProtectionNotes } from '../key-protection-notes'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), +})); + +function withShell(wrap: boolean): void { + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { + version: 1, + platform: 'linux', + appLock: { status: async () => ({ wrap }) }, + }; +} + +describe('what settings say about the key', () => { + beforeEach(() => useOfflineStore.setState({ keyUnprotected: false, rekey: {} })); + afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + }); + + it('a browser says it has no layer of the OS', async () => { + await act(async () => { render(); }); + expect(screen.getByText('browser_note')).toBeTruthy(); + }); + + it('the desktop app with a real key store says nothing', async () => { + withShell(true); + await act(async () => { render(); }); + expect(screen.queryByTestId('key-protection-notes')).toBeNull(); + }); + + it('Linux with no secret service says secrets are only obfuscated', async () => { + withShell(false); + await act(async () => { render(); }); + expect(screen.getByText('no_secure_storage')).toBeTruthy(); + expect(screen.queryByText('browser_note')).toBeNull(); + }); + + it('says out loud when the key store refused, and how a re-encryption goes', async () => { + withShell(true); + useOfflineStore.setState({ keyUnprotected: true, rekey: { a: 40, b: 'failed' } }); + await act(async () => { render(); }); + expect(screen.getByText('wrap_failed')).toBeTruthy(); + expect(screen.getByText('rekey_failed')).toBeTruthy(); + expect(screen.getByText('rekey_progress:{"percent":40}')).toBeTruthy(); + }); +}); diff --git a/components/offline/key-protection-notes.tsx b/components/offline/key-protection-notes.tsx new file mode 100644 index 000000000..ee5a1a36c --- /dev/null +++ b/components/offline/key-protection-notes.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { useOfflineStore } from '@/lib/offline/state'; + +/** + * What protects the key to the mail on this device, where that's less than one would think, and + * how a re-encryption is getting on. `browser`: also says what a browser can't do. The app lock's + * own settings have said that already. + */ +export function KeyProtectionNotes({ browser = false }: { browser?: boolean }) { + const t = useTranslations('keyProtection'); + const keyUnprotected = useOfflineStore((state) => state.keyUnprotected); + const rekey = useOfflineStore((state) => state.rekey); + const [bridge, setBridge] = useState<'none' | 'wraps' | 'obfuscates'>('wraps'); + + useEffect(() => { + const member = getDesktopBridge()?.appLock; + if (!member) { + setBridge('none'); + return; + } + let live = true; + void member.status().then((status) => { + if (live) setBridge(status.wrap ? 'wraps' : 'obfuscates'); + }).catch(() => {}); + return () => { + live = false; + }; + }, []); + + const states = Object.values(rekey); + const running = states.filter((state): state is number => typeof state === 'number'); + const lines = [ + bridge === 'obfuscates' ? t('no_secure_storage') : null, + bridge === 'none' && browser ? t('browser_note') : null, + keyUnprotected ? t('wrap_failed') : null, + states.includes('failed') ? t('rekey_failed') : null, + running.length > 0 ? t('rekey_progress', { percent: Math.min(...running) }) : null, + ].filter((line): line is string => line !== null); + if (lines.length === 0) return null; + return ( +
+ {lines.map((line) =>

{line}

)} +
+ ); +} diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index f86d066a3..244db009c 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -27,6 +27,9 @@ export function OfflineHost() { const tStorage = useTranslations('offlineStorage'); const tStorageRef = useRef(tStorage); tStorageRef.current = tStorage; + const tKey = useTranslations('keyProtection'); + const tKeyRef = useRef(tKey); + tKeyRef.current = tKey; const router = useRouter(); const routerRef = useRef(router); routerRef.current = router; @@ -89,6 +92,12 @@ export function OfflineHost() { case 'store-unavailable': toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 }); break; + case 'key-unprotected': + toast.warning(tKeyRef.current('wrap_failed'), { duration: 0 }); + break; + case 'rekey-failed': + toast.warning(tKeyRef.current('rekey_failed'), { duration: 0 }); + break; case 'refused-delete': toast.warning(tr('toast.refused_delete')); break; diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx index 1bfd3706a..0ae50fbc3 100644 --- a/components/offline/offline-settings.tsx +++ b/components/offline/offline-settings.tsx @@ -15,6 +15,7 @@ import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { AccountStorage } from './account-storage'; import { OutboxList } from './outbox-list'; +import { KeyProtectionNotes } from './key-protection-notes'; import { WipeDeviceSection } from './wipe-device-section'; /** @@ -65,6 +66,7 @@ export function OfflineSettings() {