Merge branch 'feat/desktop-offline' into feat/do-proxy-fixes

This commit is contained in:
Linus Rath
2026-11-04 03:55:52 +01:00
159 changed files with 10677 additions and 656 deletions
+3
View File
@@ -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
+23
View File
@@ -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
+43 -20
View File
@@ -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<string | null>;
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`));
}}
/>
+2
View File
@@ -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({
<ImpersonationReconciler />
<PushBindingsHost />
<DesktopOsHost />
{!IS_LITE && <EditedFilesHost />}
<FreeBusyAutoShare />
{!IS_LITE && <PopoutHost />}
</MainWindowOnly>
@@ -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<string, unknown> = {};
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(<LoginPage />);
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(<LoginPage />);
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(<LoginPage />);
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(<LoginPage />);
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(<LoginPage />);
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(<LoginPage />);
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(<LoginPage />);
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<string, unknown> | 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(<LoginPage />);
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(<LoginPage />);
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<string, string>) => 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<string, unknown>[] = [];
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(<LoginPage />);
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(<LoginPage />);
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(<LoginPage />);
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(<LoginPage />);
await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 });
expect(screen.queryByTestId('desktop-handoff-banner')).toBeNull();
}, 30_000);
});
+208 -39
View File
@@ -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<string | null>(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 && (
<div className="space-y-3">
<Button
type="button"
className="w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
onClick={handleBrowserSignIn}
disabled={desktopWaiting || isLoading}
data-testid="desktop-browser-sign-in"
>
{desktopWaiting ? <Loader2 className="w-4 h-4 animate-spin me-2" /> : <LogIn className="w-4 h-4 me-2" />}
{desktopWaiting ? tBrowser("waiting") : tBrowser("button")}
</Button>
{desktopWaiting && (
<p role="status" data-testid="desktop-browser-waiting" className="text-center text-xs text-muted-foreground leading-relaxed">
{tBrowser("waiting_hint")}{" "}
<button
type="button"
className="underline hover:text-foreground"
data-testid="desktop-browser-cancel"
onClick={() => {
cancelBrowserSignIn();
setDesktopWaiting(false);
}}
>
{tBrowser("cancel")}
</button>
</p>
)}
<div className="relative py-1">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/60" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-background/80 px-3 text-muted-foreground">{tBrowser("or_here")}</span>
</div>
</div>
</div>
);
// The desktop app's sign-in, in the browser: says what this page is for.
const desktopHandoffBanner = isDesktopHandoff && (
<p data-testid="desktop-handoff-banner" className="mb-4 text-center text-sm text-muted-foreground leading-relaxed">
{tBrowser("handoff_banner")}
</p>
);
const desktopPolicyAlert = desktopPolicyNotice && (
<p role="alert" data-testid="desktop-policy-notice" className="mt-2 text-sm text-warning leading-relaxed">
{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() {
</div>
)}
{desktopHandoffBanner}
{/* Dev Mode: One-click login */}
{devMode ? (
<div className="space-y-4">
@@ -1629,10 +1797,14 @@ function LoginPageContent() {
{serverPicker}
{desktopPolicyAlert}
{oauthMetadata && showRememberMe && rememberMeBox}
{browserSignInOption}
{oauthMetadata ? (
<Button
type="button"
className="w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
variant={browserSignInShown ? "outline" : undefined}
className={browserSignInShown
? "w-full h-11 font-medium text-[0.9375rem] rounded-xl border-border/60 hover:bg-muted/50"
: "w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"}
onClick={() => handleOAuthLogin()}
disabled={oauthLoading}
>
@@ -1663,6 +1835,11 @@ function LoginPageContent() {
// Where the sign-in button goes once the provider answers.
<Block className="w-full h-11 rounded-xl" />
)}
{desktopNotice && (
<p role="alert" data-testid="desktop-notice" className="mt-2 text-sm text-warning leading-relaxed">
{desktopNotice}
</p>
)}
</div>
) : (
/* Login Form */
@@ -1690,6 +1867,8 @@ function LoginPageContent() {
</div>
)}
{browserSignInOption}
{/* Username field. The access token names the account, so
token sign-in has none. */}
<div className={cn("space-y-1.5", signInWithToken && "hidden")}>
@@ -1897,7 +2076,10 @@ function LoginPageContent() {
<Button
type="submit"
className="w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
variant={browserSignInShown ? "outline" : undefined}
className={browserSignInShown
? "w-full h-11 font-medium text-[0.9375rem] rounded-xl border-border/60 hover:bg-muted/50"
: "w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"}
disabled={isLoading || (liteSsoRequired && oauthLoading)}
>
{isLoading || (liteSsoRequired && oauthLoading) ? (
@@ -1944,21 +2126,8 @@ function LoginPageContent() {
</>
)}
{process.env.NEXT_PUBLIC_BULWARK_DESKTOP === "1" && !hasServerList && browserSignInAvailable() && (
<Button
type="button"
variant="outline"
className="mt-3 w-full h-11 font-medium text-[0.9375rem] rounded-xl border-border/60 hover:bg-muted/50"
onClick={handleDesktopBrowserSignIn}
disabled={desktopWaiting || isLoading}
data-testid="desktop-browser-sign-in"
>
{desktopWaiting ? <Loader2 className="w-4 h-4 animate-spin me-2" /> : <LogIn className="w-4 h-4 me-2" />}
{desktopWaiting ? tDesktop("browser_waiting") : tDesktop("browser_button")}
</Button>
)}
{desktopPolicyAlert}
{IS_DESKTOP_BUILD && desktopNotice && (
{desktopNotice && (
<p role="alert" data-testid="desktop-notice" className="mt-2 text-sm text-warning leading-relaxed">
{desktopNotice}
</p>
+1
View File
@@ -133,6 +133,7 @@ export function SettingsPage() {
</SettingsSection>
<SettingsSection id="login" title="Login page">
<ConfigToggleItem label="Desktop app: sign in with the browser" description="Lets the desktop app sign in through this login page in the user's browser, so their password manager works there. Turn off on a terminal server or any computer several people use at once: another user there can send a link that asks for someone's sign-in. The app's own form keeps working." configKey="desktopBrowserSignIn" value={currentValue('desktopBrowserSignIn') as boolean} source={config.desktopBrowserSignIn?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show version" description="Show the webmail version in the login page footer. Turn off to keep the exact version hidden from visitors who are not signed in." configKey="loginShowVersion" value={currentValue('loginShowVersion') as boolean} source={config.loginShowVersion?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show 2FA code option" term="two_factor" description="Show the &quot;I have a 2FA code&quot; toggle on the login form. Turn off when two-factor authentication is handled by an external identity provider. Accounts that require a code still get the field." configKey="loginShowTotp" value={currentValue('loginShowTotp') as boolean} source={config.loginShowTotp?.source} onChange={handleChange} onRevert={handleRevert} />
<ConfigToggleItem label="Show access token sign-in" term="access_token" description="Offer signing in with an access token instead of a password. For JMAP servers that authenticate API tokens as Bearer tokens, such as Fastmail, which accepts no passwords over JMAP." configKey="loginShowTokenLogin" value={currentValue('loginShowTokenLogin') as boolean} source={config.loginShowTokenLogin?.source} onChange={handleChange} onRevert={handleRevert} />
@@ -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);
}
}
@@ -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 });
}
}
+136
View File
@@ -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<string, string> = 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<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const clientSecret =
entry?.oauth?.clientSecret
|| configManager.get<string>('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<typeof stashHandoff>[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);
}
}
@@ -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<string, unknown> = {}) {
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);
}
}
+42 -20
View File
@@ -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<string> {
async function postRegistration(endpoint: string, redirectUri: string, scope: string): Promise<Response> {
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<string | null> {
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' });
+5
View File
@@ -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 });
+51
View File
@@ -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<string> {
try {
const claims = JSON.parse(Buffer.from((idToken ?? '').split('.')[1] ?? '', 'base64url').toString('utf8')) as Record<string, unknown>;
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,
+12 -2
View File
@@ -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.
+1
View File
@@ -89,6 +89,7 @@ export async function GET(request: NextRequest) {
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
loginShowTokenLogin: configManager.get<boolean>('loginShowTokenLogin', false),
desktopBrowserSignIn: configManager.get<boolean>('desktopBrowserSignIn', true),
loginShowRememberMe: configManager.get<boolean>('loginShowRememberMe', false),
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
demoMode: configManager.get<boolean>('demoMode', false),
+59 -3
View File
@@ -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<string, string> = { '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<string> {
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 () => {
+18 -6
View File
@@ -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=<id>[&credential=<id>] - 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;
+3 -2
View File
@@ -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<StalwartAuthContext[]> {
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;
+18 -8
View File
@@ -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<string, string>): Promise<ResolvedTarget | 'ambiguous' | null> {
// 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<string, string>): Promise<ResolvedTarget | 'ambiguous' | null> {
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<stri
);
}
const results = (await Promise.all(probes)).filter((r) => 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 });
}
+12 -6
View File
@@ -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")}
/>
);
}
@@ -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<boolean>)[]);
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<boolean>) => {
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<string, unknown> = {}) => ({
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<string, ReturnType<typeof vi.fn>>; dialogs: { confirm: ReturnType<typeof vi.fn> }; os: { notify: ReturnType<typeof vi.fn> } };
function show(...sessions: ReturnType<typeof session>[]) {
bridge.edit.list.mockResolvedValue(sessions);
useEditSessions.setState({ sessions });
return render(<EditedFilesHost />);
}
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(<EditedFilesHost />);
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(<EditedFilesHost />);
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();
});
});
+212
View File
@@ -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<string> {
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<string | null>(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<string>());
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<void>) => {
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 (
<section
aria-label={t("offers_label")}
data-testid="edited-files"
// Toasts have one corner and the account menu the other, and both have to stay reachable: over the mail columns, under any menu or dialog.
className="fixed inset-x-0 bottom-4 z-40 mx-auto flex max-h-[60vh] w-[22rem] max-w-[calc(100vw-2rem)] flex-col gap-2 overflow-y-auto"
>
{held.length > 0 && (
<div data-testid="edited-file-held" role="status" className="flex flex-col gap-1 rounded-lg border border-border bg-background p-3 text-xs shadow-lg">
{heldNames.length > 0 && <p className="break-words text-foreground" dir="auto">{t("held_named", { names: heldNames.join(", ") })}</p>}
{heldOthers > 0 && <p className="text-foreground">{t("held_unnamed")}</p>}
<p className="text-muted-foreground">{t("outside_note")}</p>
</div>
)}
{offers.map((session) => {
const source = parseEditSource(session.source);
const draftId = source.kind === "draft" && !session.savedAs ? source.draftId : null;
const account = accountLabel(session);
return (
<div key={session.id} data-testid="edited-file" className="flex flex-col gap-2 rounded-lg border border-border bg-background p-3 shadow-lg">
<div className="flex items-start gap-2">
<FilePen className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="break-words text-sm font-medium text-foreground" dir="auto">{t("changed_title", { name: session.name })}</p>
{account && <p className="truncate text-xs text-muted-foreground" data-testid="edited-file-account">{account}</p>}
<p className="text-xs text-muted-foreground">{t("changed_body")}</p>
{session.lastChance && <p className="text-xs font-medium text-foreground" data-testid="edited-file-last-chance">{t("last_chance")}</p>}
</div>
<button
type="button"
onClick={() => void getEditBridge()?.discard(session.id)}
title={t("discard")}
aria-label={`${t("discard")} ${session.name}`}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-wrap gap-2">
{source.kind === "mail" && (
<Button size="sm" disabled={busy === session.id} onClick={() => void attachToReply(session, source.emailId, source.ownerAccountId)}>{t("reply")}</Button>
)}
{draftId && (
<Button size="sm" onClick={() => openDraftWindow({ draftId, localAccountId: session.account, ownerAccountId: source.kind === "draft" ? source.ownerAccountId : undefined })}>{t("open_draft")}</Button>
)}
{source.kind !== "mail" && !draftId && (
<Button size="sm" disabled={busy === session.id} onClick={() => void attachToNew(session)}>{t("new_message")}</Button>
)}
<Button size="sm" variant="outline" disabled={busy === session.id} onClick={() => void saveCopy(session)}>{t("save_copy")}</Button>
</div>
</div>
);
})}
</section>
);
}
@@ -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<typeof import('@/lib/offline/offline-mode')>()),
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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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: '<p>Hello there</p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: 'draft-1',
};
const session = (over: Record<string, unknown> = {}) => ({
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<typeof fakeEdit>;
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(<EmailComposer initialData={DRAFT_WITH_FILE} onSend={onSend as never} onClose={vi.fn()} />);
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<typeof vi.fn> } }).dialogs.confirm).toHaveBeenCalledTimes(1);
});
it('with nothing open for editing, Send looks at nothing', async () => {
const onSend = vi.fn(async () => {});
render(<EmailComposer initialData={REOPENED_DRAFT} onSend={onSend as never} onClose={vi.fn()} />);
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
await waitFor(() => expect(onSend).toHaveBeenCalled());
expect(edit.settle).not.toHaveBeenCalled();
});
const forward = (phishingMarked: boolean) => (
<EmailComposer
mode="forward"
replyTo={{ subject: 'Original', phishingMarked, attachments: [{ blobId: 'b1', name: 'a.txt', type: 'text/plain', size: 3 }] }}
onClose={vi.fn()}
/>
);
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(<EmailComposer initialData={{ ...REOPENED_DRAFT, draftId: null, attachments: [{ blobId: 'b1', name: 'a.txt', type: 'text/plain', size: 3, editBlocked: true }] }} onClose={vi.fn()} />);
await screen.findByText('a.txt');
expect(screen.queryByTestId('composer-edit')).toBeNull();
});
});
@@ -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<typeof import('@/lib/offline/offline-mode')>()),
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<string, unknown> = {}) => ({ partId: '2', blobId: 'blob-1', name: 'report.docx', type: 'application/octet-stream', size: 10, disposition: 'attachment', ...over });
function makeEmail(attachment: Record<string, unknown>, keywords: Record<string, boolean> = { $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<typeof vi.fn>;
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(<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onArchive={vi.fn()} onDelete={vi.fn()} />);
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(
<ThreadConversationView
thread={{ threadId: 't1', emails: [email], latestEmail: email } as never}
emails={[email] as never}
onBack={() => {}}
/>,
);
}
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();
});
});
@@ -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() });
@@ -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) => `<p></p><p data-reply-quote-start="true">On Tuesday, C wrote:</p><div data-quoted-html=""><p>Original text</p><img src="${src}" alt="logo" data-cid="logo@x"></div>${below}`;
async function openReply(below: string) {
let editor: Editor | null = null;
const element = (content: string) => (
<RichTextEditor content={content} onChange={() => {}} 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', '<p></p>'],
['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('<p></p>', '<p>Hel</p>'));
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('<p>A long line below the quote</p>');
act(() => { editor.commands.setTextSelection(editor.state.doc.content.size - 2); });
setContent('<p>Short</p>');
type(editor, '!');
expect(editor.state.doc.textContent).toContain('Short');
expect(editor.state.doc.textContent).toContain('!');
});
});
+70 -1
View File
@@ -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<string, string | string[]>;
/** 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<HTMLDivElement | null>(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<Blob> => {
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<ComposerAttachment>({
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({
) : (
<div className="flex items-center gap-2 min-w-0">{label}</div>
)}
{composerEdits.hasUpdate(att) && !att.uploading && (
<button
type="button"
onClick={() => void composerEdits.update(att)}
className="-my-0.5 whitespace-nowrap text-xs font-medium text-primary hover:underline"
data-testid="composer-edit-update"
>
{tEdit('update')}
</button>
)}
{!att.uploading && !att.error && composerEdits.canEditChip(att) && (
<button
type="button"
onClick={() => void composerEdits.startEdit(att)}
className={cn("-my-0.5 hover:text-foreground text-muted-foreground min-w-6 min-h-6 flex items-center justify-center", TOUCH_HIT_AREA)}
title={tEdit('edit')}
aria-label={tEdit('edit_named', { name: att.name })}
data-testid="composer-edit"
>
<FilePen className="w-3 h-3" />
</button>
)}
<button
onClick={() => removeAttachment(index)}
className={cn("ms-0.5 -my-0.5 hover:text-destructive min-w-6 min-h-6 flex items-center justify-center", TOUCH_HIT_AREA)}
+17
View File
@@ -14,6 +14,7 @@ import { useMailReading } from "@/hooks/use-mail-reading";
import { useReadAloud } from "@/hooks/use-read-aloud";
import { ReadAloudBar } from "./read-aloud-bar";
import { ReaderAttachments, type AttachmentDragProps } from "./reader-attachments";
import { editAccountOf, useEditAttachment } from "@/hooks/use-edit-attachment";
import { ReaderViewDialog } from "./reader-view-dialog";
import { MessagePrint, type MessagePrintFields } from "./message-print";
import { usePrintListener, usePrinting } from "@/components/print/print-root";
@@ -3171,6 +3172,8 @@ export function EmailViewer({
const attachmentCardsRef = useRef<HTMLDivElement>(null);
const desktopClipboard = getDesktopBridge()?.clipboard;
const tDesktopUi = useTranslations('desktopUi.attachment');
const editAccount = editAccountOf(email);
const { canEdit: canEditAttachment, edit: editAttachment } = useEditAttachment(editAccount);
const readerAttachmentProps = {
attachments: effectiveAttachments,
thumbUrls: imageThumbUrls,
@@ -3179,6 +3182,20 @@ export function EmailViewer({
onDownload: handleEffectiveAttachmentDownload,
onDownloadAll: handleDownloadAllAttachments,
onSaveAllToFolder: canPickFolder() ? () => void handleSaveAllToFolder() : undefined,
onEdit: (attachment: EffectiveAttachment) => {
if (!attachment.blobId || !blobClient || !email) return;
const { blobId } = attachment;
void editAttachment({
key: `mail:${editAccount}:${email.id}:${blobId}`,
name: resolveAttachmentName(attachment),
type: attachment.type,
account: editAccount,
source: { kind: 'mail', emailId: email.id, ownerAccountId: blobAccountId },
load: () => blobClient.fetchBlobArrayBuffer(blobId, undefined, undefined, blobAccountId),
});
},
// Decrypted parts have no blob: they never go on disk this way. A message marked as phishing doesn't get opened in other programs.
canEdit: (attachment: EffectiveAttachment) => !!attachment.blobId && !!blobClient && !phishingMarked && canEditAttachment(resolveAttachmentName(attachment)),
onCopyFile: desktopClipboard && blobClient ? (attachment: EffectiveAttachment) => {
if (!attachment.blobId || !downloadAllowed('attachment', email)) return;
void blobClient.fetchBlobArrayBuffer(attachment.blobId, undefined, undefined, blobAccountId)
+16
View File
@@ -23,6 +23,22 @@ const QUOTED_REMOTE_ALLOWED_ATTR = "data-quoted-remote";
// Atom blocks typing must never replace just because they were left selected.
const ATOM_BLOCKS = new Set(["quotedHtml", "signatureBlock"]);
/**
* Replacing the editor's content throws the caret to where the old selection
* maps to: the quote as a node, where the next key typed replaces the whole
* quote, or the line below it. There is no focus event for the guard below to
* catch: a reply's inline images finish loading while the caret already waits
* in the body. It goes back to where it was, or to the start if that is gone.
*/
export function restoreCaretAfterContentSync(editor: Editor, caret: number): void {
const { doc } = editor.state;
const near = Selection.near(doc.resolve(Math.min(caret, doc.content.size)));
const target = near instanceof TextSelection ? near : Selection.atStart(doc);
if (target instanceof TextSelection && !target.eq(editor.state.selection)) {
editor.view.dispatch(editor.state.tr.setSelection(target));
}
}
// Reusable style for the quote bar when quoting email text (like in a reply).
const QUOTE_BAR_STYLE =
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
+12
View File
@@ -58,6 +58,12 @@ interface ReaderAttachmentsProps<T extends ReaderAttachmentItem> {
* has on an attachment; a browser has its own menu there and never gets this one.
*/
onCopyFile?: (attachment: T) => void;
/**
* The desktop app's Edit: opens the file in its program. `canEdit` says no where the file
* type or the organisation's rules keep it out.
*/
onEdit?: (attachment: T) => void;
canEdit?: (attachment: T) => boolean;
/** Whether a click on the attachment previews it instead of downloading it. */
opensPreview: (attachment: T) => boolean;
onOpen: (attachment: T) => void;
@@ -106,6 +112,8 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
onDownloadAll,
onSaveAllToFolder,
onCopyFile,
onEdit,
canEdit,
downloadingAll,
downloadsOff = false,
onSaveToFiles,
@@ -118,6 +126,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
const tDevice = useTranslations("device_policy");
const tWindows = useTranslations("pwaWindows");
const tDesktop = useTranslations("desktopUi.attachment");
const tEdit = useTranslations("desktopEdit");
const menu = useContextMenu<T>();
const [desktopMenu, setDesktopMenu] = useState(false);
useEffect(() => setDesktopMenu(nativeMenusAvailable()), []);
@@ -351,6 +360,9 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
{!downloadsOff && (
<ContextMenuItem label={tDesktop("save_as")} onClick={() => { menu.closeContextMenu(); onDownload(menuTarget); }} />
)}
{!downloadsOff && onEdit && canEdit?.(menuTarget) && (
<ContextMenuItem label={tEdit("edit")} onClick={() => { menu.closeContextMenu(); onEdit(menuTarget); }} />
)}
{!downloadsOff && onCopyFile && (
<ContextMenuItem label={tDesktop("copy")} onClick={() => { menu.closeContextMenu(); onCopyFile(menuTarget); }} />
)}
+3 -1
View File
@@ -24,7 +24,7 @@ import { TableRow } from "@tiptap/extension-table-row";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { ReplyQuoteCollapse } from "@/components/email/reply-quote-collapse";
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
import { QuotedHtml, restoreCaretAfterContentSync, serializeEditorContent } from "@/components/email/quoted-html";
import { keepLoadedParagraphSpacing, paragraphStyleWithoutSpacing, trailingParagraphBreakRule } from "@/components/email/paragraph-breaks";
import { SignatureBlock } from "@/components/email/signature-block";
import { styledBlockAttributes } from "@/components/email/styled-block-attributes";
@@ -614,7 +614,9 @@ export function RichTextEditor({
if (!editor) return;
const loaded = keepLoadedParagraphSpacing(content);
if (loaded !== serializeEditorContent(editor)) {
const caret = editor.state.selection.from;
editor.commands.setContent(loaded, { emitUpdate: false });
restoreCaretAfterContentSync(editor, caret);
}
}, [content, editor]);
@@ -51,6 +51,8 @@ import {
} from "@/components/icons";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { useTranslations } from "next-intl";
import { FilePen } from "@/components/icons";
import { editAccountOf, useEditAttachment } from "@/hooks/use-edit-attachment";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext } from "@/stores/email-store";
@@ -589,6 +591,8 @@ export function ConversationEmailBody({
const fallbackSource = useEmailStore(useShallow(() => resolveEmailBlobContext(email, client)));
const source = blobSource ?? fallbackSource;
const { client: blobClient, accountId } = source;
const editAccount = source.clientAccountId ?? editAccountOf(email);
const { canEdit: canEditAttachment, edit: editAttachment } = useEditAttachment(editAccount);
// Reported as phishing: a banner, links off, no cards (as in the reader).
const phishingMarked = usePhishingMarked(email);
const tPhishing = useTranslations('phishing_report');
@@ -1072,6 +1076,28 @@ export function ConversationEmailBody({
<Download className="w-4 h-4 text-muted-foreground" />
)}
</button>
{blobClient && attachment.blobId && !phishingMarked && canEditAttachment(attachment.name) && (
<button
type="button"
title={t('desktopEdit.edit')}
aria-label={t('desktopEdit.edit_named', { name: stripBidiControls(attachment.name ?? '') })}
className="-ms-1.5 self-center rounded-lg p-2 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
const { blobId } = attachment;
void editAttachment({
key: `mail:${editAccount}:${email.id}:${blobId}`,
name: attachment.name ?? 'attachment',
type: attachment.type,
account: editAccount,
source: { kind: 'mail', emailId: email.id, ownerAccountId: accountId },
load: () => blobClient.fetchBlobArrayBuffer(blobId, undefined, undefined, accountId),
});
}}
>
<FilePen className="h-4 w-4" />
</button>
)}
<AttachmentPluginMenu emailId={email.id} attachment={attachment} className="-ms-1.5 self-center p-2 rounded-lg" />
</React.Fragment>
);
@@ -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 () => {
+1
View File
@@ -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,
@@ -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<TitlebarArea | null>(null);
useEffect(() => watchTitlebarArea((next) => {
+5
View File
@@ -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
@@ -0,0 +1,137 @@
import { render, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
/**
* A reply, forward or forward-as-attachment from a Pro message view names the
* login holding the message. The composer defaults its From to that account and
* copies the original's parts from it; without the login it takes the message
* for the active account's, so the reply goes out from the wrong account, or
* fails on a blobId that account doesn't have (#1511).
*/
const viewerProps = vi.hoisted(() => ({ current: null as Record<string, unknown> | null }));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: (props: Record<string, unknown>) => {
viewerProps.current = props;
return null;
},
}));
vi.mock('@/components/error', () => ({
ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
EmailViewerErrorFallback: () => null,
}));
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } }));
vi.mock('@/hooks/use-pro-multi-account-identities', async (importOriginal) => ({
...await importOriginal<typeof import('@/hooks/use-pro-multi-account-identities')>(),
useProMultiAccountIdentities: () => ({ enabled: true, groups: [], allIdentities: [] }),
}));
const { openComposeTab } = vi.hoisted(() => ({ openComposeTab: vi.fn() }));
function selectorStore<T extends object>(state: T) {
const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state);
hook.getState = () => state;
return hook;
}
const auth = vi.hoisted(() => ({
state: { client: null as unknown, activeAccountId: 'login-a', getClientForAccount: () => undefined },
}));
vi.mock('@/stores/auth-store', () => ({ useAuthStore: selectorStore(auth.state) }));
const MESSAGE = vi.hoisted(() => ({
id: 'eaaaaab',
blobId: 'blob-of-message',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: { $seen: true },
from: [{ email: 'sender@example.com', name: 'Sender' }],
to: [{ email: 'b@example.org' }],
subject: 'Hello',
receivedAt: '2026-01-01T00:00:00Z',
bodyValues: { '1': { value: '<img src="cid:logo">' } },
textBody: [],
htmlBody: [{ partId: '1', type: 'text/html' }],
attachments: [{ blobId: 'blob-of-logo', name: 'logo.png', type: 'image/png', size: 10, cid: 'logo', disposition: 'inline' }],
}));
vi.mock('@/stores/email-store', () => ({
resolveEmailBlobContext: (_email: unknown, client: unknown) => ({ client, clientAccountId: auth.state.activeAccountId }),
useEmailStore: selectorStore({
fetchEmailContent: async () => MESSAGE,
deleteEmail: async () => {},
markAsRead: async () => {},
toggleStar: async () => {},
moveToMailbox: async () => {},
setEmailKeywordsLocal: () => {},
mailboxes: [],
emails: [],
selectedEmail: null,
}),
}));
vi.mock('@/stores/identity-store', () => ({ useIdentityStore: selectorStore({ identities: [] }) }));
vi.mock('@/stores/settings-store', () => ({ useSettingsStore: selectorStore({ emailDownloadTemplate: '' }) }));
vi.mock('@/stores/pro-tab-store', () => ({
useProTabStore: selectorStore({ openComposeTab, closeTab: () => {}, updateTabTitle: () => {} }),
}));
import { ProEmailView } from '../pro-email-tab-body';
const ACTIONS = ['onReply', 'onReplyAll', 'onForward', 'onForwardAsAttachment'] as const;
async function open(props: { clientAccountId?: string; accountId?: string; override?: boolean }) {
const client = { getEmail: vi.fn().mockResolvedValue(MESSAGE), getUsername: () => 'b@example.org', getAccountId: () => 'jmap-b' };
const { override = true, ...rest } = props;
if (!override) auth.state.client = client;
const view = render(<ProEmailView emailId="eaaaaab" client={override ? client as never : undefined} {...rest} onClose={vi.fn()} />);
await waitFor(() => expect(viewerProps.current?.email).toBeTruthy());
return { ...view, client };
}
function composeFrom(action: typeof ACTIONS[number]) {
openComposeTab.mockClear();
(viewerProps.current![action] as () => void)();
expect(openComposeTab).toHaveBeenCalledTimes(1);
return openComposeTab.mock.calls[0][0].replyTo as Record<string, unknown>;
}
describe('compose actions of a Pro message view', () => {
beforeEach(() => {
viewerProps.current = null;
openComposeTab.mockClear();
auth.state.activeAccountId = 'login-a';
auth.state.client = null;
});
it.each(ACTIONS)('%s of another login names that login', async (action) => {
await open({ clientAccountId: 'login-b' });
expect(composeFrom(action).accountId).toBe('login-b');
});
it.each(ACTIONS)('%s of a shared folder of another login names the login, not the owner', async (action) => {
await open({ clientAccountId: 'login-b', accountId: 'jmap-team' });
expect(composeFrom(action).accountId).toBe('login-b');
});
it.each(ACTIONS)('%s of the active login names the active login', async (action) => {
await open({ override: false });
expect(composeFrom(action).accountId).toBe('login-a');
});
it('keeps naming the login the message was read in after the active account changes', async () => {
// A message of b isn't foreign while b is active, so it carries no stamp.
auth.state.activeAccountId = 'login-b';
const { rerender, client } = await open({ clientAccountId: 'login-b' });
auth.state.activeAccountId = 'login-a';
rerender(<ProEmailView emailId="eaaaaab" client={client as never} clientAccountId="login-b" onClose={vi.fn()} />);
expect(composeFrom('onReply').accountId).toBe('login-b');
});
});
+18 -5
View File
@@ -34,6 +34,7 @@ import { takeTabReply, useNotificationTabReply } from '@/lib/pwa-notify/tab-repl
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
import { canDownload } from '@/lib/device-policy';
import { phishingMarkedNow } from '@/hooks/use-phishing-report';
import { useOpenEmailInWindow } from '@/hooks/use-popouts-available';
import { isFilePreviewable } from '@/lib/file-preview';
import { useDesktopCommands } from '@/lib/desktop/commands';
@@ -111,8 +112,19 @@ export function stampProSource(email: Email, source: EmailBlobContext, loginId:
return { ...email, sourceClientAccountId: login, sourceAccountId: account };
}
function buildReplyContext(email: Email): ProReplyContext {
/**
* The login holding the message, for ProReplyContext.accountId: the composer
* defaults its From to that account and copies the original's parts from it
* (#1104, #1511). email.accountId can't say: it is for display, and a message
* read through client.getEmail() has none.
*/
function replyLogin(email: Email, source: EmailWriteSource): string | undefined {
return email.sourceClientAccountId ?? source.clientAccountId;
}
function buildReplyContext(email: Email, source: EmailWriteSource): ProReplyContext {
return {
phishingMarked: phishingMarkedNow(email),
from: email.from,
replyToAddresses: email.replyTo,
to: email.to,
@@ -122,7 +134,7 @@ function buildReplyContext(email: Email): ProReplyContext {
subject: email.subject,
...getQuoteBodies(email),
receivedAt: email.receivedAt,
accountId: email.accountId,
accountId: replyLogin(email, source),
attachments: email.attachments,
messageId: email.messageId,
inReplyTo: email.inReplyTo,
@@ -285,7 +297,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
if (!email) return;
openComposeTab({
mode: 'reply',
replyTo: buildReplyContext(email),
replyTo: buildReplyContext(email, writeSource),
sourceEmailId: email.id,
sourceEmailSource: writeSource,
initialDraftText: draftText,
@@ -305,7 +317,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
if (!email) return;
openComposeTab({
mode: 'replyAll',
replyTo: buildReplyContext(email),
replyTo: buildReplyContext(email, writeSource),
sourceEmailId: email.id,
sourceEmailSource: writeSource,
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
@@ -316,7 +328,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
if (!email) return;
openComposeTab({
mode: 'forward',
replyTo: buildReplyContext(email),
replyTo: buildReplyContext(email, writeSource),
sourceEmailId: email.id,
sourceEmailSource: writeSource,
title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
@@ -355,6 +367,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
attachments: [payload.attachment],
headers: labelSourceHeaders(email.headers),
originalFrom: email.from,
accountId: replyLogin(email, writeSource),
},
sourceEmailId: email.id,
sourceEmailSource: writeSource,
@@ -0,0 +1,58 @@
import { render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NotificationActionsHost } from '../notification-actions-host';
// The worker shows nothing for a login with email notifications off or do not
// disturb on. Each login has its own settings, so the answer goes per login.
const writeLoginQuiet = vi.fn(async (..._args: unknown[]) => {});
let state = { isAuthenticated: true, username: 'me@example.org' as string | null, emailNotificationsEnabled: true, doNotDisturb: false };
vi.mock('@/lib/push-preferences', () => ({ writeLoginQuiet: (...args: unknown[]) => writeLoginQuiet(...args), PUSH_STATE_CACHE: 'x' }));
vi.mock('@/lib/pwa-notify/worker-state', () => ({ writeNotificationActionPrefs: async () => {} }));
vi.mock('@/lib/pwa-notify/page-actions', () => ({ isNotificationActionRequest: () => false, runNotificationAction: async () => {} }));
vi.mock('@/lib/pwa-notify/plugin-veto', () => ({ pluginsMayVeto: () => false }));
vi.mock('@/lib/offline/offline-mode', () => ({ scheduleFlushAll: () => {} }));
vi.mock('@/lib/archive-gate', () => ({ useArchiveActionEnabled: () => false }));
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
vi.mock('@/stores/auth-store', () => ({ useAuthStore: (select: (s: typeof state) => unknown) => select(state) }));
vi.mock('@/stores/settings-store', () => ({
useSettingsStore: (select: (s: typeof state & { archiveMode: string }) => unknown) => select({ ...state, archiveMode: 'single' }),
}));
vi.mock('@/stores/plugin-store', () => ({ usePluginStore: (select: (s: { plugins: unknown[] }) => unknown) => select({ plugins: [] }) }));
vi.mock('@/stores/account-store', () => {
const useAccountStore = (select: (s: { accounts: unknown[] }) => unknown) => select({ accounts: [] });
useAccountStore.getState = () => ({ accounts: [] });
return { useAccountStore };
});
beforeEach(() => {
writeLoginQuiet.mockClear();
state = { isAuthenticated: true, username: 'me@example.org', emailNotificationsEnabled: true, doNotDisturb: false };
});
describe('what the page tells the worker about a login', () => {
it('not quiet with everything on', () => {
render(<NotificationActionsHost />);
expect(writeLoginQuiet).toHaveBeenCalledWith('me@example.org', false);
});
it('quiet with email notifications off', () => {
state.emailNotificationsEnabled = false;
render(<NotificationActionsHost />);
expect(writeLoginQuiet).toHaveBeenCalledWith('me@example.org', true);
});
it('quiet with do not disturb on', () => {
state.doNotDisturb = true;
render(<NotificationActionsHost />);
expect(writeLoginQuiet).toHaveBeenCalledWith('me@example.org', true);
});
it('nothing while signed out', () => {
state.isAuthenticated = false;
state.username = null;
render(<NotificationActionsHost />);
expect(writeLoginQuiet).not.toHaveBeenCalled();
});
});
@@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
import { useArchiveActionEnabled } from '@/lib/archive-gate';
import { scheduleFlushAll } from '@/lib/offline/offline-mode';
import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
import { writeLoginQuiet } from '@/lib/push-preferences';
import { isNotificationActionRequest, runNotificationAction } from '@/lib/pwa-notify/page-actions';
import { pluginsMayVeto } from '@/lib/pwa-notify/plugin-veto';
import { writeNotificationActionPrefs } from '@/lib/pwa-notify/worker-state';
@@ -42,6 +43,16 @@ export function NotificationActionsHost() {
});
}, [isAuthenticated, archiveEnabled, archiveMode, mayVeto, archive, markRead, reply, replyPlaceholder]);
// Email notifications off or do not disturb on, for the login on screen: the
// worker shows nothing for that login's mail. The label a push carries is this
// name (lib/web-push.ts), and each login has its own settings.
const username = useAuthStore((s) => s.username);
const quiet = useSettingsStore((s) => !s.emailNotificationsEnabled || s.doNotDisturb);
useEffect(() => {
if (!isAuthenticated || !username) return;
void writeLoginQuiet(username, quiet);
}, [isAuthenticated, username, quiet]);
// Which logins this tab holds: if it stays silent on a click for one of them,
// the worker opens the message rather than go round its plugins.
const loginKey = useMemo(() => accounts.map((a) => `${a.serverUrl}\n${a.username}`).join('\n\n'), [accounts]);
+4 -6
View File
@@ -19,8 +19,8 @@ import {
resyncWebPush,
waitForWebPushResync,
} from "@/lib/web-push";
import { webPushErrorKey } from "@/lib/web-push-errors";
import { resolveActiveRelayUrl } from "@/lib/push-relays";
import { jmapErrorType } from "@/lib/jmap/jmap-error";
import {
isPWAInstallPromptVisible,
PWA_INSTALL_PROMPT_VISIBILITY_EVENT,
@@ -310,11 +310,9 @@ export function PushNotificationPrompt() {
});
setShowPrompt(false);
} catch (err) {
// Readable text for the server refusals a user can act on.
const type = jmapErrorType(err);
if (type === "forbidden") setError(tPush("error_forbidden"));
else if (type === "overQuota") setError(tPush("error_over_quota"));
else setError(err instanceof Error ? err.message : "Failed to enable push");
// Readable text for what a user can act on.
const key = webPushErrorKey(err);
setError(key ? tPush(key) : err instanceof Error ? err.message : "Failed to enable push");
} finally {
setIsEnabling(false);
}
@@ -1,6 +1,6 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest';
import { NotificationSettings } from '../notification-settings';
import { useSettingsStore } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -137,6 +137,25 @@ describe('NotificationSettings - pushNotifyInboxOnly', () => {
});
});
describe('NotificationSettings - demo mode', () => {
beforeEach(() => {
useAuthStore.setState({ client: fakeClient, username: 'me@example.com', isDemoMode: true });
vi.clearAllMocks();
});
afterEach(() => {
useAuthStore.setState({ isDemoMode: false });
});
it('says push is not available and offers no switch that would call the relay', async () => {
const { findByText, getAllByRole } = render(<NotificationSettings />);
await findByText('push.status_demo');
const pushSwitch = getAllByRole('switch').find((el) => (el as HTMLButtonElement).disabled);
expect(pushSwitch).toBeTruthy();
expect(webPush.enableWebPush).not.toHaveBeenCalled();
});
});
describe('NotificationSettings - mailboxes shared by ACL', () => {
const mailbox = (id: string, accountId: string, isShared: boolean) =>
({ id, originalId: id, name: id, accountId, isShared }) as unknown as Mailbox;
@@ -39,6 +39,7 @@ function useLockFacts() {
/** The app lock's section on the General page: it has to exist on any server. */
export function AppLockSettings() {
const t = useTranslations("appLock");
const tEdit = useTranslations("desktopEdit");
const { appName } = useConfig();
const { on, required, timeout, update } = useLockFacts();
const [pending, setPending] = useState<Pending>(null);
@@ -178,6 +179,10 @@ export function AppLockSettings() {
</>
)}
{on && getDesktopBridge()?.edit && (
<p className="text-xs text-muted-foreground" data-testid="app-lock-edit-note">{tEdit("lock_note")}</p>
)}
<Dialog
isOpen={pending !== null}
onClose={close}
+21 -8
View File
@@ -9,6 +9,7 @@ import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { disableWebPush, enableWebPushForAccounts, isWebPushEnabled, isWebPushSupported } from '@/lib/web-push';
import { webPushErrorKey } from '@/lib/web-push-errors';
interface FailedAccount {
id: string;
@@ -17,11 +18,6 @@ interface FailedAccount {
reason: string;
}
function failureReason(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return String(error);
}
/**
* Turn push on or off for several accounts at once.
*
@@ -32,9 +28,22 @@ function failureReason(error: unknown): string {
*/
export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: string }) {
const t = useTranslations('settings.notifications.push_all');
const tPush = useTranslations('settings.notifications.push');
const failureReason = (error: unknown): string => {
const key = webPushErrorKey(error);
if (key) return tPush(key);
return error instanceof Error && error.message ? error.message : String(error);
};
const failureOf = (account: (typeof accounts)[number], error: unknown): FailedAccount => ({
id: account.id,
name: account.displayName || account.label || account.username,
email: account.email || account.username,
reason: failureReason(error),
});
const accounts = useAccountStore(s => s.accounts);
// Re-read the client map whenever a connection appears or drops.
const revision = useAuthStore(s => s.connectedAccountsRevision);
const isDemoMode = useAuthStore(s => s.isDemoMode);
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
const [chosen, setChosen] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
@@ -59,7 +68,7 @@ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: stri
useEffect(() => { void refresh(); }, [refresh, revision]);
if (!isWebPushSupported() || connected.length < 2) return null;
if (!isWebPushSupported() || isDemoMode || connected.length < 2) return null;
const toggle = (id: string, on: boolean) => setChosen(prev => {
const next = new Set(prev);
@@ -95,11 +104,15 @@ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: stri
};
}));
} else {
// Teardown swallows its own failures, so every account ends up local-off.
// Every account ends up off here; one the server or relay couldn't be
// told about is named, since it may keep getting notifications for days.
const failed: FailedAccount[] = [];
for (const account of targets) {
await disableWebPush({ client: auth.getClientForAccount(account.id)!, relayBaseUrl });
await disableWebPush({ client: auth.getClientForAccount(account.id)!, relayBaseUrl })
.catch((error: unknown) => { failed.push(failureOf(account, error)); });
}
setNotice(t('result_disabled', { count: targets.length }));
setFailures(failed);
}
setChosen(new Set());
} finally {
+11 -19
View File
@@ -5,7 +5,6 @@ import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import { useIdentityStore } from '@/stores/identity-store';
import { jmapErrorType } from '@/lib/jmap/jmap-error';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
@@ -27,6 +26,7 @@ import {
revokePushDevice,
serverSupportsEmailPush,
} from '@/lib/web-push';
import { webPushErrorKey } from '@/lib/web-push-errors';
import type { PushDevice } from '@/lib/web-push';
import { IS_LITE } from '@/lib/lite';
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
@@ -72,7 +72,10 @@ export function NotificationSettings() {
const username = useAuthStore((s) => s.username);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const supported = typeof window !== 'undefined' && isWebPushSupported();
// The demo client has no mail server to subscribe with. Enabling would get as
// far as a relay record and a browser subscription, then fail.
const isDemoMode = useAuthStore((s) => s.isDemoMode);
const supported = typeof window !== 'undefined' && isWebPushSupported() && !isDemoMode;
// "Inbox only" is enforced by the server's emailPush filter; without that
// capability (Stalwart < 0.16.16) the toggle would do nothing.
const inboxOnlyAvailable = supported && !!client && serverSupportsEmailPush(client);
@@ -145,7 +148,7 @@ export function NotificationSettings() {
// In the static Lite build push is off by design (no service worker, see
// components/service-worker-registration.tsx), not a browser limitation.
const statusDescription = pushStatus.kind === 'unsupported'
? (IS_LITE ? t('push.status_lite') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`)
? (IS_LITE ? t('push.status_lite') : isDemoMode ? t('push.status_demo') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`)
: noRelay && !pushEnabled
? tThirdParty('push_needs_relay')
: busy
@@ -171,14 +174,9 @@ export function NotificationSettings() {
// The refusals a user can do something about get readable text; anything
// else keeps the error's own message.
const pushErrorMessage = (err: unknown, fallback: string) => {
switch (jmapErrorType(err)) {
case 'forbidden':
return t('push.error_forbidden');
case 'overQuota':
return t('push.error_over_quota');
default:
return err instanceof Error ? err.message : fallback;
}
const key = webPushErrorKey(err);
if (key) return t(`push.${key}`);
return err instanceof Error ? err.message : fallback;
};
// `forceRecreate` is what the Re-register button is for: without it the enable
@@ -265,10 +263,7 @@ export function NotificationSettings() {
await disableWebPush({ client, relayBaseUrl: activeRelayUrl });
setPushStatus({ kind: 'idle' });
} catch (err) {
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to disable push',
});
setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to disable push') });
} finally {
await refreshDevices();
}
@@ -290,10 +285,7 @@ export function NotificationSettings() {
await revokePushDevice({ client, device, relayBaseUrl: activeRelayUrl });
if (device.isThisDevice) setPushStatus({ kind: 'idle' });
} catch (err) {
setPushStatus({
kind: 'error',
message: err instanceof Error ? err.message : 'Failed to revoke device',
});
setPushStatus({ kind: 'error', message: pushErrorMessage(err, 'Failed to revoke device') });
} finally {
setRevokingId(null);
await refreshDevices();
+12
View File
@@ -209,6 +209,18 @@
<list id="KeystoreAllowedOrigins_Value" valuePrefix=""/>
</elements>
</policy>
<policy name="BrowserSignIn" class="Both" displayName="$(string.BrowserSignIn)" explainText="$(string.BrowserSignIn_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="BrowserSignIn">
<parentCategory ref="Bulwark_Desktop_signIn"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="BrowserSignIn_Recommended" class="Both" displayName="$(string.BrowserSignIn)" explainText="$(string.BrowserSignIn_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="BrowserSignIn">
<parentCategory ref="Bulwark_Desktop_Recommended"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="WipeOfflineData" class="Both" displayName="$(string.WipeOfflineData)" explainText="$(string.WipeOfflineData_Explain)" presentation="$(presentation.WipeOfflineData)" key="Software\Policies\Bulwark\Desktop">
<parentCategory ref="Bulwark_Desktop_security"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
+2
View File
@@ -56,6 +56,8 @@
<string id="AutoSelectCertificateForUrls_Explain">Eine JSON-Regel pro Zeile, im Format von Chrome: {&quot;pattern&quot;:&quot;https://[*.]example.com&quot;,&quot;filter&quot;:{&quot;ISSUER&quot;:{&quot;CN&quot;:&quot;Example CA&quot;}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage.</string>
<string id="KeystoreAllowedOrigins">Websites, die den Zertifikatspeicher des Systems verwenden dürfen</string>
<string id="KeystoreAllowedOrigins_Explain">Die Bulwark-Server, nach https-Adresse, deren Plugins mit den Zertifikaten im Speicher des Betriebssystems signieren und entschlüsseln dürfen, ohne dass der Benutzer für jede Website gefragt wird. Eine pro Zeile.</string>
<string id="BrowserSignIn">Anmeldung über den Browser anbieten</string>
<string id="BrowserSignIn_Explain">Wenn aktiviert oder nicht konfiguriert, bietet die Anmeldeseite „Mit Ihrem Browser anmelden“ an, damit Benutzer dort ihren Passwort-Manager, Passkeys und Ihr Single Sign-on verwenden können. Wenn deaktiviert, fehlt die Schaltfläche und die App lehnt den Vorgang ab. Das Formular in der App bleibt, sofern der Server nicht nur OAuth erlaubt.</string>
<string id="WipeOfflineData">Auf dem Gerät behaltene E-Mails löschen</string>
<string id="WipeOfflineData_Explain">Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.</string>
</stringTable>
+2
View File
@@ -56,6 +56,8 @@
<string id="AutoSelectCertificateForUrls_Explain">One JSON rule per line, in the format Chrome uses: {&quot;pattern&quot;:&quot;https://[*.]example.com&quot;,&quot;filter&quot;:{&quot;ISSUER&quot;:{&quot;CN&quot;:&quot;Example CA&quot;}}}. The app then picks the matching certificate for that site without asking.</string>
<string id="KeystoreAllowedOrigins">Sites that may use the system certificate store</string>
<string id="KeystoreAllowedOrigins_Explain">The Bulwark servers, by https address, whose plugins may sign and decrypt with the certificates in the operating system&apos;s store, without the user being asked for each site. One per line.</string>
<string id="BrowserSignIn">Offer sign-in through the browser</string>
<string id="BrowserSignIn_Explain">When enabled or not configured, the login page offers &quot;Sign in with your browser&quot;, so people can use their password manager, passkeys and your single sign-on there. When disabled, the button is gone and the app refuses the flow. The form in the app stays, unless the server only allows OAuth.</string>
<string id="WipeOfflineData">Delete mail kept on the device</string>
<string id="WipeOfflineData_Explain">Set this to a new value, such as today&apos;s date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.</string>
</stringTable>
@@ -61,6 +61,8 @@
<array>
<string>https://mail.example.com</string>
</array>
<key>BrowserSignIn</key>
<true/>
<key>Recommended</key>
<dict>
<key>UpdateChannel</key>
+1
View File
@@ -25,6 +25,7 @@
"KeystoreAllowedOrigins": [
"https://mail.example.com"
],
"BrowserSignIn": true,
"Recommended": {
"UpdateChannel": "stable"
}
+334
View File
@@ -0,0 +1,334 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { useState } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const policy = vi.hoisted(() => ({ allowed: true }));
const toastError = vi.hoisted(() => vi.fn());
const forbidden = vi.hoisted(() => new Set<string>());
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
vi.mock('@/stores/toast-store', () => ({ toast: { error: toastError, success: vi.fn() } }));
vi.mock('@/hooks/use-device-policy', () => ({
useDevicePolicy: () => ({ noDownloads: !policy.allowed, noExport: false }),
}));
vi.mock('@/lib/device-policy', () => ({
canDownload: (account?: string) => policy.allowed && !forbidden.has(account ?? ''),
canExport: () => true,
deviceSubjectOf: () => null,
}));
vi.mock('@/lib/offline/offline-mode', () => ({ scopeDecided: () => true }));
vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ activeAccountId: 'acct' }) } }));
import { useComposerEdits, type EditableAttachment } from '../use-composer-edits';
import { parkFilesForReply, readEditedFile, resetEditSessions, useEditSessions } from '@/lib/desktop/edit-attachments';
function fakeBridge() {
return {
canEdit: vi.fn((name: string) => !name.endsWith('.exe')),
begin: vi.fn(async () => ({ ok: true as const, ticket: 't1' })),
open: vi.fn(async () => ({ ok: true as const, id: 's1' })),
reopen: vi.fn(async () => ({ ok: true as const, id: 's1' })),
list: vi.fn(async () => []),
settle: vi.fn(async () => useEditSessions.getState().sessions),
read: vi.fn(async () => ({ ok: true as const, name: 'a.docx', type: 'x/y', data: new Uint8Array([1, 2]), hash: 'h2' })),
taken: vi.fn(async () => {}),
release: vi.fn(async () => {}),
discard: vi.fn(async () => ({ held: [] })),
forceDiscard: vi.fn(async () => ({ held: [] })),
askClear: vi.fn(async () => true),
clear: vi.fn(async () => ({ held: [] })),
wipe: vi.fn(async () => ({ held: [] })),
shown: vi.fn(),
setSource: vi.fn(),
claim: vi.fn(),
onChange: vi.fn(() => () => {}),
};
}
let bridge: ReturnType<typeof fakeBridge>;
let confirm: ReturnType<typeof vi.fn>;
const session = (over: Partial<{ id: string; name: string; source: string; changed: boolean; claimed: boolean; hash: string }> = {}) => ({
id: 's1', name: 'a.docx', type: 'x/y', account: 'acct', source: '{"kind":"draft","draftId":"d1"}', changed: false, version: 0, hash: 'current', claimed: true, held: false, savedAs: false, lastChance: false, ...over,
});
const chipNamed = (name: string): EditableAttachment => ({ name, type: 'x/y', blobId: 'b' });
interface Setup {
start: EditableAttachment[];
draftId?: string | null;
repliesTo?: string;
/** What the composer's upload does with a file. */
upload?: (file: File) => Partial<EditableAttachment>;
}
function setup({ start, draftId = 'd1', repliesTo, upload = () => ({ blobId: 'new-blob' }) }: Setup) {
const load = vi.fn(async () => new Blob(['x']));
bridge.list.mockResolvedValue(useEditSessions.getState().sessions as never);
return renderHook(() => {
const [attachments, setAttachments] = useState<EditableAttachment[]>(start);
const addFiles = async (files: File[]) => {
setAttachments((list) => [...list, ...files.map((file) => ({ file, name: file.name, type: file.type, uploading: false, ...upload(file) }))]);
};
const edits = useComposerEdits<EditableAttachment>({ attachments, setAttachments, addFiles, load, account: 'acct', draftId, repliesTo, ready: true });
return { attachments, setAttachments, edits };
});
}
beforeEach(() => {
policy.allowed = true;
bridge = fakeBridge();
confirm = vi.fn(async () => 0);
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, edit: bridge, dialogs: { confirm } };
resetEditSessions();
toastError.mockClear();
});
afterEach(() => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
describe('updating a chip', () => {
const chip: EditableAttachment = { name: 'a.docx', type: 'x/y', blobId: 'old-blob', editSessionId: 's1' };
it('takes the old file off only once the new one is up, and marks that version as taken', async () => {
useEditSessions.setState({ sessions: [session({ changed: true })] });
const { result } = setup({ start: [chip] });
expect(result.current.edits.hasUpdate(result.current.attachments[0])).toBe(true);
await act(() => result.current.edits.update(result.current.attachments[0]));
await waitFor(() => expect(result.current.attachments).toHaveLength(1));
const [only] = result.current.attachments;
expect(only.blobId).toBe('new-blob');
expect(only.file?.name).toBe('a.docx');
await waitFor(() => expect(only.editSessionId ?? result.current.attachments[0].editSessionId).toBe('s1'));
await waitFor(() => expect(bridge.taken).toHaveBeenCalledWith('s1', 'h2'));
// Stays held by the composer throughout: it's the same session.
expect(bridge.discard).not.toHaveBeenCalled();
});
it('keeps the old file and the offer when the upload fails', async () => {
useEditSessions.setState({ sessions: [session({ changed: true })] });
const { result } = setup({ start: [chip], upload: () => ({ error: true }) });
await act(() => result.current.edits.update(result.current.attachments[0]));
await waitFor(() => expect(result.current.attachments).toHaveLength(1));
expect(result.current.attachments[0].blobId).toBe('old-blob');
expect(result.current.edits.hasUpdate(result.current.attachments[0])).toBe(true);
expect(bridge.taken).not.toHaveBeenCalled();
});
it('says so when the program still holds the file', async () => {
bridge.read.mockResolvedValueOnce({ ok: false, reason: 'busy' } as never);
useEditSessions.setState({ sessions: [session({ changed: true })] });
const { result } = setup({ start: [chip] });
await act(() => result.current.edits.update(result.current.attachments[0]));
expect(toastError).toHaveBeenCalledWith('read_busy');
expect(result.current.attachments[0].blobId).toBe('old-blob');
});
it('refuses when the organisation has said no since', async () => {
policy.allowed = false;
useEditSessions.setState({ sessions: [session({ changed: true })] });
const { result } = setup({ start: [chip] });
await act(() => result.current.edits.update(result.current.attachments[0]));
expect(bridge.read).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledWith('blocked_policy');
expect(result.current.attachments).toHaveLength(1);
});
});
describe('starting from a chip', () => {
it('fetches a server part, and the chip takes the session on', async () => {
const { result } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b1' }] });
await act(() => result.current.edits.startEdit(result.current.attachments[0]));
expect(bridge.begin).toHaveBeenCalledWith('blob:acct:b1');
await waitFor(() => expect(result.current.attachments[0].editSessionId).toBe('s1'));
expect(bridge.claim).toHaveBeenCalledWith('s1', true);
const [, file] = bridge.open.mock.calls[0] as unknown as [string, { source: string }];
expect(JSON.parse(file.source)).toEqual({ kind: 'draft', draftId: 'd1' });
});
it('a chip with a session goes back to that session', async () => {
const { result } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b1', editSessionId: 's1' }] });
await act(() => result.current.edits.startEdit(result.current.attachments[0]));
expect(bridge.begin).toHaveBeenCalledWith('session:s1');
});
it('offers it only for what the shell and the organisation allow', () => {
const { result } = setup({ start: [] });
expect(result.current.edits.canEditChip(chipNamed('a.docx'))).toBe(true);
expect(result.current.edits.canEditChip(chipNamed('a.exe'))).toBe(false);
});
it('offers nothing under a policy', () => {
policy.allowed = false;
const { result } = setup({ start: [] });
expect(result.current.edits.canEditChip(chipNamed('a.docx'))).toBe(false);
});
});
describe('holding sessions', () => {
it('claims what is on a chip, follows the draft id, and lets go when it closes, which is no end', async () => {
const { result, rerender, unmount } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b', editSessionId: 's1' }] });
expect(bridge.claim).toHaveBeenCalledWith('s1', true);
expect(bridge.setSource).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'draft', draftId: 'd1' }));
rerender();
expect(result.current.attachments).toHaveLength(1);
unmount();
expect(bridge.claim).toHaveBeenLastCalledWith('s1', false);
expect(bridge.discard).not.toHaveBeenCalled();
});
it('a chip taken off leaves the decision to main, which knows about a save from a moment ago', async () => {
useEditSessions.setState({ sessions: [session({ id: 's1' }), session({ id: 's2', name: 'b.docx', changed: true })] });
const { result } = setup({ start: [
{ name: 'a.docx', type: 'x/y', blobId: 'b', editSessionId: 's1' },
{ name: 'b.docx', type: 'x/y', blobId: 'c', editSessionId: 's2' },
] });
act(() => result.current.setAttachments([]));
await waitFor(() => expect(bridge.release).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'file' })));
expect(bridge.release).toHaveBeenCalledWith('s2', JSON.stringify({ kind: 'file' }));
expect(bridge.discard).not.toHaveBeenCalled();
});
it('a draft opened again finds its session by id and name', async () => {
useEditSessions.setState({ sessions: [session({ claimed: false, changed: true })] });
const { result } = setup({ start: [{ name: 'other.pdf', type: 'x/y', blobId: 'z' }, { name: 'a.docx', type: 'x/y', blobId: 'b' }] });
await waitFor(() => expect(result.current.attachments[1].editSessionId).toBe('s1'));
expect(result.current.attachments[0].editSessionId).toBeUndefined();
expect(result.current.edits.hasUpdate(result.current.attachments[1])).toBe(true);
});
it('another draft leaves it alone', async () => {
useEditSessions.setState({ sessions: [session({ claimed: false, source: '{"kind":"draft","draftId":"other"}' })] });
const { result } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b' }] });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(result.current.attachments[0].editSessionId).toBeUndefined();
});
});
describe('sending', () => {
const chip: EditableAttachment = { name: 'a.docx', type: 'x/y', blobId: 'b', editSessionId: 's1' };
it('goes on without a question when nothing waits', async () => {
useEditSessions.setState({ sessions: [session()] });
const { result } = setup({ start: [chip] });
await expect(result.current.edits.confirmSend()).resolves.toBe(true);
expect(confirm).not.toHaveBeenCalled();
});
it('asks before an older version goes out, with going back as the default', async () => {
useEditSessions.setState({ sessions: [session({ changed: true })] });
const { result } = setup({ start: [chip] });
confirm.mockResolvedValueOnce(1);
await expect(result.current.edits.confirmSend()).resolves.toBe(false);
expect(confirm.mock.calls[0][0]).toMatchObject({ buttons: ['send_stale_send', 'send_stale_back'], defaultId: 1 });
confirm.mockResolvedValueOnce(0);
await expect(result.current.edits.confirmSend()).resolves.toBe(true);
});
it('sees a save from a moment ago, which the list has not heard of', async () => {
useEditSessions.setState({ sessions: [session()] });
const { result } = setup({ start: [chip] });
bridge.settle.mockResolvedValueOnce([session({ changed: true })] as never);
confirm.mockResolvedValueOnce(1);
await expect(result.current.edits.confirmSend()).resolves.toBe(false);
expect(confirm).toHaveBeenCalledTimes(1);
});
it('a chip holding the version on disk has nothing to ask about, even before main hears it went up', async () => {
const outcome = await readEditedFile({ id: 's1', account: 'acct' });
if (!outcome.ok) throw new Error('refused');
useEditSessions.setState({ sessions: [session({ changed: true, hash: outcome.hash })] });
const { result } = setup({ start: [{ file: outcome.file, name: 'a.docx', type: 'x/y', editSessionId: 's1', uploading: true }] });
expect(result.current.edits.hasUpdate(result.current.attachments[0])).toBe(false);
await expect(result.current.edits.confirmSend()).resolves.toBe(true);
expect(confirm).not.toHaveBeenCalled();
});
it('sent or discarded: main decides, also on a flag this page has wrong', async () => {
// The page thinks nothing changed. Main has a save from a moment ago, and keeps it.
useEditSessions.setState({ sessions: [session({ id: 's1' }), session({ id: 's2', name: 'b.docx', changed: true })] });
const { result } = setup({ start: [chip, { name: 'b.docx', type: 'x/y', blobId: 'c', editSessionId: 's2' }] });
act(() => result.current.edits.finish());
expect(bridge.release).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'file' }));
expect(bridge.release).toHaveBeenCalledWith('s2', JSON.stringify({ kind: 'file' }));
expect(bridge.claim).toHaveBeenCalledWith('s2', false);
expect(bridge.discard).not.toHaveBeenCalled();
});
it('says whether a send has anything to ask about', () => {
const { result } = setup({ start: [chip] });
expect(result.current.edits.holdsEdits()).toBe(true);
const { result: other } = setup({ start: [chipNamed('a.docx')] });
expect(other.current.edits.holdsEdits()).toBe(false);
});
});
describe('a forwarded part', () => {
it('from a message marked as phishing has no Edit', () => {
const { result } = setup({ start: [] });
expect(result.current.edits.canEditChip({ ...chipNamed('a.docx'), editBlocked: true })).toBe(false);
});
it('from another login follows that login\'s rules, and its session is filed there', async () => {
const { result } = setup({ start: [{ ...chipNamed('a.docx'), sourceAccountId: 'other' }] });
expect(result.current.edits.canEditChip(result.current.attachments[0])).toBe(true);
await act(() => result.current.edits.startEdit(result.current.attachments[0]));
expect(bridge.begin).toHaveBeenCalledWith('blob:other:b');
const [, file] = bridge.open.mock.calls[0] as unknown as [string, { account: string }];
expect(file.account).toBe('other');
});
it('from a login whose organisation says no has no Edit, whatever this one says', () => {
const { result } = setup({ start: [] });
forbidden.add('other');
try {
expect(result.current.edits.canEditChip({ ...chipNamed('a.docx'), sourceAccountId: 'other' })).toBe(false);
expect(result.current.edits.canEditChip(chipNamed('a.docx'))).toBe(true);
} finally {
forbidden.clear();
}
});
});
describe('a reply from an offer', () => {
it('takes the waiting file, and the session comes with it', async () => {
const outcome = await readEditedFile({ id: 's1', account: 'acct' });
if (!outcome.ok) throw new Error('refused');
parkFilesForReply('e1', [outcome.file]);
const { result } = setup({ start: [], repliesTo: 'e1', draftId: null });
await waitFor(() => expect(result.current.attachments.map((a) => a.name)).toEqual(['a.docx']));
await waitFor(() => expect(result.current.attachments[0].editSessionId).toBe('s1'));
await waitFor(() => expect(bridge.taken).toHaveBeenCalledWith('s1', 'h2'));
expect(bridge.claim).toHaveBeenCalledWith('s1', true);
});
it('a version counts as taken only once it is up', async () => {
const outcome = await readEditedFile({ id: 's1', account: 'acct' });
if (!outcome.ok) throw new Error('refused');
parkFilesForReply('e1', [outcome.file]);
const { result } = setup({ start: [], repliesTo: 'e1', draftId: null, upload: () => ({ uploading: true }) });
await waitFor(() => expect(result.current.attachments[0]?.editSessionId).toBe('s1'));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(bridge.taken).not.toHaveBeenCalled();
act(() => result.current.setAttachments((list) => list.map((att) => ({ ...att, uploading: false, blobId: 'up' }))));
await waitFor(() => expect(bridge.taken).toHaveBeenCalledWith('s1', 'h2'));
});
it('a reply to another message leaves it waiting', async () => {
parkFilesForReply('e1', [new File(['x'], 'a.docx')]);
const { result } = setup({ start: [], repliesTo: 'e2', draftId: null });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(result.current.attachments).toEqual([]);
});
});
describe('in a browser', () => {
it('nothing happens at all', () => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
const { result } = setup({ start: [{ name: 'a.docx', type: 'x/y', blobId: 'b' }] });
expect(result.current.edits.canEditChip(chipNamed('a.docx'))).toBe(false);
expect(result.current.attachments[0].editSessionId).toBeUndefined();
expect(() => result.current.edits.finish()).not.toThrow();
});
});
+140
View File
@@ -0,0 +1,140 @@
import { renderHook, waitFor, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
// /api/wopi/status needs the sign-in context cookie. The mail and files views
// can mount before it exists, so a 401 there says nothing about the editor and
// must not leave office editing off until the next page load (#1518).
const ENABLED = { enabled: true, editExtensions: ['docx', 'odt'], viewExtensions: ['pdf'], newExtensions: ['docx'] };
const answer = (status: number, body: unknown = {}) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
let fetchMock: Mock<(url: string, init?: RequestInit) => Promise<unknown>>;
let useWopiStatus: typeof import('@/hooks/use-wopi-status').useWopiStatus;
let useAuthStore: typeof import('@/stores/auth-store').useAuthStore;
const signIn = (client: object) => useAuthStore.setState({ isAuthenticated: true, client: client as never });
beforeEach(async () => {
fetchMock = vi.fn();
// Only the status route counts: the auth store asks for other things on import.
vi.stubGlobal('fetch', (url: string, init?: RequestInit) => (
url.includes('/api/wopi/status') ? fetchMock(url, init) : Promise.resolve(answer(404))
));
// The hook shares its answer through module state, like a page load does.
vi.resetModules();
({ useWopiStatus } = await import('@/hooks/use-wopi-status'));
({ useAuthStore } = await import('@/stores/auth-store'));
useAuthStore.setState({ isAuthenticated: false, client: null });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('useWopiStatus', () => {
it('asks nothing before sign-in, and asks once sign-in is done', async () => {
fetchMock.mockResolvedValue(answer(200, ENABLED));
const { result } = renderHook(() => useWopiStatus(true));
await Promise.resolve();
expect(fetchMock).not.toHaveBeenCalled();
signIn({});
await waitFor(() => expect(result.current?.enabled).toBe(true));
expect(result.current?.editExtensions).toContain('docx');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('asks nothing while the persisted flag says signed in but no client is connected yet', async () => {
fetchMock.mockResolvedValue(answer(200, ENABLED));
useAuthStore.setState({ isAuthenticated: true, client: null });
renderHook(() => useWopiStatus(true));
await Promise.resolve();
expect(fetchMock).not.toHaveBeenCalled();
});
it('turns editing on when a 401 is followed by the real client taking over', async () => {
// A local copy is on screen before the restore writes the context cookie.
fetchMock.mockResolvedValueOnce(answer(401)).mockResolvedValue(answer(200, ENABLED));
signIn({ kind: 'local copy' });
const { result } = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(result.current).not.toBeNull());
expect(result.current?.enabled).toBe(false);
signIn({ kind: 'real' });
await waitFor(() => expect(result.current?.enabled).toBe(true));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('does not share a 401 still in flight with the client that took over', async () => {
let finishFirst: (value: unknown) => void = () => {};
fetchMock
.mockReturnValueOnce(new Promise((resolve) => { finishFirst = resolve; }))
.mockResolvedValue(answer(200, ENABLED));
signIn({ kind: 'local copy' });
const { result } = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
signIn({ kind: 'real' });
await waitFor(() => expect(result.current?.enabled).toBe(true));
finishFirst(answer(401));
// The late refusal does not take the answer back, for this view or the next.
await new Promise((resolve) => setTimeout(resolve));
expect(result.current?.enabled).toBe(true);
const next = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(next.result.current?.enabled).toBe(true));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it.each([401, 500, 502])('asks again on the next mount after a %i', async (status) => {
fetchMock.mockResolvedValueOnce(answer(status)).mockResolvedValue(answer(200, ENABLED));
signIn({});
const first = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(first.result.current).not.toBeNull());
expect(first.result.current?.enabled).toBe(false);
const second = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(second.result.current?.enabled).toBe(true));
});
it('keeps a real answer for the page, across views and account switches', async () => {
fetchMock.mockResolvedValue(answer(200, ENABLED));
signIn({ account: 'a' });
const first = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(first.result.current?.enabled).toBe(true));
const second = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(second.result.current?.enabled).toBe(true));
signIn({ account: 'b' });
await waitFor(() => expect(useAuthStore.getState().client).toEqual({ account: 'b' }));
await new Promise((resolve) => setTimeout(resolve));
expect(first.result.current?.enabled).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('keeps a real "no editor" answer too, so mail views do not keep asking', async () => {
fetchMock.mockResolvedValue(answer(200, { enabled: false }));
signIn({});
const first = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(first.result.current?.enabled).toBe(false));
const second = renderHook(() => useWopiStatus(true));
await waitFor(() => expect(second.result.current?.enabled).toBe(false));
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('asks nothing while the caller has it switched off', async () => {
fetchMock.mockResolvedValue(answer(200, ENABLED));
signIn({});
const { result } = renderHook(() => useWopiStatus(false));
await Promise.resolve();
expect(fetchMock).not.toHaveBeenCalled();
expect(result.current).toBeNull();
});
});
+234
View File
@@ -0,0 +1,234 @@
"use client";
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from "react";
import { useTranslations } from "next-intl";
import { editAllowed, editFileInfo, followEditSessions, getEditBridge, parseEditSource, readEditedFile, takeFilesForReply, useEditSessions, type EditSource } from "@/lib/desktop/edit-attachments";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { useEditAttachment } from "@/hooks/use-edit-attachment";
import { toast } from "@/stores/toast-store";
/** What this needs from a composer's attachment. */
export interface EditableAttachment {
file?: File;
name: string;
type: string;
blobId?: string;
uploading?: boolean;
error?: boolean;
/** The Edit session this chip's file belongs to. Kept through every state change of the chip. */
editSessionId?: string;
/** The login the part really belongs to, on a forward: its organisation's rules are the ones that count. */
sourceAccountId?: string;
/** From a message marked as phishing: not opened in other programs here either. */
editBlocked?: boolean;
}
interface Options<A extends EditableAttachment> {
attachments: A[];
setAttachments: Dispatch<SetStateAction<A[]>>;
addFiles: (files: File[]) => Promise<void>;
load: (attachment: A) => Promise<Blob>;
/** The local account the draft goes through. */
account: string;
draftId: string | null;
ownerAccountId?: string;
/** The message a reply answers: a file waiting for that reply lands here. */
repliesTo?: string;
/** Only once the composer can upload. */
ready: boolean;
}
/**
* A composer's part of Edit: its chips start sessions and hold them, so the offer to update
* sits on the chip rather than in the main window. A session goes when the draft is sent or
* discarded, unless it has edits nobody took: those stay, for the user to decide on.
*/
export function useComposerEdits<A extends EditableAttachment>({ attachments, setAttachments, addFiles, load, account, draftId, ownerAccountId, repliesTo, ready }: Options<A>) {
const t = useTranslations("desktopEdit");
const { canEdit, edit } = useEditAttachment(account);
const available = getEditBridge() !== null;
const sessions = useEditSessions((s) => s.sessions);
const instanceRef = useRef(`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
const heldRef = useRef(new Set<string>());
const takenRef = useRef(new WeakSet<File>());
const latest = useRef({ attachments, sessions });
latest.current = { attachments, sessions };
useEffect(() => {
if (available) followEditSessions();
}, [available]);
// A file the user sent into a reply from an offer.
const repliedRef = useRef(false);
useEffect(() => {
if (!available || !ready || repliedRef.current || !repliesTo) return;
repliedRef.current = true;
const files = takeFilesForReply(repliesTo);
if (files.length > 0) void addFiles(files);
}, [available, ready, repliesTo, addFiles]);
// A file read back from a session: its chip takes the session on, and once it's up the
// version it holds is no longer a change.
useEffect(() => {
if (!available) return;
const untagged = attachments.some((att) => !att.editSessionId && editFileInfo(att.file));
if (untagged) {
setAttachments((list) => list.map((att) => {
const info = att.editSessionId ? undefined : editFileInfo(att.file);
return info ? { ...att, editSessionId: info.id } : att;
}));
}
for (const att of attachments) {
const info = editFileInfo(att.file);
if (!info || !att.file || !att.blobId || att.uploading || att.error || takenRef.current.has(att.file)) continue;
takenRef.current.add(att.file);
void getEditBridge()?.taken(info.id, info.hash);
}
}, [available, attachments, setAttachments]);
// A draft opened again finds its sessions by the draft's id and the file's name.
useEffect(() => {
if (!available || !draftId) return;
const mine = sessions.filter((session) => {
const source = parseEditSource(session.source);
return source.kind === "draft" && source.draftId === draftId && !session.claimed;
});
if (mine.length === 0) return;
setAttachments((list) => {
let changed = false;
const next = list.map((att) => {
if (att.editSessionId) return att;
const match = mine.find((session) => session.name === att.name && !list.some((other) => other.editSessionId === session.id));
if (!match) return att;
changed = true;
return { ...att, editSessionId: match.id };
});
return changed ? next : list;
});
}, [available, draftId, sessions, setAttachments]);
// Holds what's on a chip. One taken off a chip goes unless it has edits, which stay in the offers.
const heldIds = attachments.flatMap((att) => (att.editSessionId ? [att.editSessionId] : []));
const heldKey = [...new Set(heldIds)].sort().join("\n");
useEffect(() => {
const bridge = getEditBridge();
if (!bridge) return;
const now = new Set(heldKey ? heldKey.split("\n") : []);
for (const id of now) {
if (!heldRef.current.has(id)) bridge.claim(id, true);
}
for (const id of heldRef.current) {
if (now.has(id)) continue;
bridge.claim(id, false);
void bridge.release(id, JSON.stringify({ kind: "file" } satisfies EditSource));
}
heldRef.current = now;
}, [heldKey]);
// Follows the draft's id, which changes with every save.
useEffect(() => {
const bridge = getEditBridge();
if (!bridge || !heldKey) return;
const source = JSON.stringify({ kind: "draft", draftId, ownerAccountId } satisfies EditSource);
for (const id of heldKey.split("\n")) bridge.setSource(id, source);
}, [heldKey, draftId, ownerAccountId]);
// Closing the composer isn't an end: the draft may be opened again.
useEffect(() => () => {
const bridge = getEditBridge();
for (const id of heldRef.current) bridge?.claim(id, false);
heldRef.current = new Set();
}, []);
const sessionOf = useCallback((att: A) => (att.editSessionId ? sessions.find((s) => s.id === att.editSessionId) : undefined), [sessions]);
/** Whether this chip has saved edits to take. Its own file on its way up isn't one. */
const hasUpdate = useCallback((att: A) => {
const session = sessionOf(att);
return session?.changed === true && editFileInfo(att.file)?.hash !== session.hash;
}, [sessionOf]);
/** The part's own account decides too, on a forward from another login. */
const canEditChip = useCallback((att: A) => (
!att.editBlocked && canEdit(att.name) && (!att.sourceAccountId || editAllowed(att.sourceAccountId))
), [canEdit]);
const startEdit = useCallback(async (att: A) => {
const owner = att.sourceAccountId ?? account;
const key = att.editSessionId
? `session:${att.editSessionId}`
: att.blobId ? `blob:${owner}:${att.blobId}` : `composer:${instanceRef.current}:${att.name}`;
const result = await edit({
key,
name: att.name,
type: att.type,
account: owner,
source: { kind: "draft", draftId, ownerAccountId },
load: () => load(att),
});
if (result.ok) {
setAttachments((list) => list.map((item) => (
(att.file ? item.file === att.file : item.blobId === att.blobId) ? { ...item, editSessionId: result.id } : item
)));
}
}, [account, draftId, edit, load, ownerAccountId, setAttachments]);
/** The edited file goes up first. Only then does the old one come off, so a failed upload loses nothing. */
const update = useCallback(async (att: A) => {
const id = att.editSessionId;
if (!id) return;
const outcome = await readEditedFile({ id, account });
if (!outcome.ok) {
toast.error(t(outcome.reason === "busy" ? "read_busy" : outcome.reason === "policy" ? "blocked_policy" : "read_failed", { name: att.name }));
return;
}
const { file } = outcome;
await addFiles([file]);
setAttachments((list) => {
const added = list.find((item) => item.file === file);
if (added?.blobId && !added.error) return list.filter((item) => item.file === file || item.editSessionId !== id);
return list.filter((item) => item.file !== file);
});
}, [account, addFiles, setAttachments, t]);
/** Whether a send has anything to ask about. */
const holdsEdits = useCallback(() => latest.current.attachments.some((att) => att.editSessionId), []);
/** Edits on the way out in an older version: asks first. False: don't send. */
const confirmSend = useCallback(async (): Promise<boolean> => {
const { attachments: list } = latest.current;
// Someone who saves and goes straight to Send is the case this is for: the watcher may not have seen it yet.
const all = await getEditBridge()?.settle() ?? latest.current.sessions;
const stale = list.flatMap((att) => {
const session = att.editSessionId ? all.find((s) => s.id === att.editSessionId) : undefined;
return session?.changed && editFileInfo(att.file)?.hash !== session.hash ? [session] : [];
});
const dialogs = getDesktopBridge()?.dialogs;
if (stale.length === 0 || !dialogs) return true;
const choice = await dialogs.confirm({
type: "warning",
message: t("send_stale_title", { name: stale[0].name }),
detail: t("send_stale_detail"),
buttons: [t("send_stale_send"), t("send_stale_back")],
defaultId: 1,
});
return choice === 0;
}, [t]);
/**
* The draft was sent or discarded. Main decides what goes, as only it knows about a save from a
* moment ago. Reads no state of the component: this can run after it has gone.
*/
const finish = useCallback(() => {
const bridge = getEditBridge();
if (!bridge) return;
const ids = new Set(latest.current.attachments.flatMap((att) => (att.editSessionId ? [att.editSessionId] : [])));
for (const id of ids) {
heldRef.current.delete(id);
bridge.claim(id, false);
void bridge.release(id, JSON.stringify({ kind: "file" } satisfies EditSource));
}
}, []);
return { canEditChip, startEdit, hasUpdate, update, holdsEdits, confirmSend, finish };
}
+4
View File
@@ -40,6 +40,7 @@ export interface ConfigData {
loginShowSubtitle: boolean;
loginShowTotp: boolean;
loginShowTokenLogin: boolean;
desktopBrowserSignIn: boolean;
loginShowRememberMe: boolean;
loginShowVersion: boolean;
demoMode: boolean;
@@ -199,6 +200,7 @@ export function useConfig(): AppConfig {
loginShowSubtitle: configCache?.loginShowSubtitle ?? true,
loginShowTotp: configCache?.loginShowTotp ?? true,
loginShowTokenLogin: configCache?.loginShowTokenLogin ?? false,
desktopBrowserSignIn: configCache?.desktopBrowserSignIn ?? true,
loginShowRememberMe: configCache?.loginShowRememberMe ?? false,
loginShowVersion: configCache?.loginShowVersion ?? true,
demoMode: configCache?.demoMode || false,
@@ -252,6 +254,7 @@ export function useConfig(): AppConfig {
loginShowSubtitle: configCache.loginShowSubtitle,
loginShowTotp: configCache.loginShowTotp,
loginShowTokenLogin: configCache.loginShowTokenLogin,
desktopBrowserSignIn: configCache.desktopBrowserSignIn,
loginShowRememberMe: configCache.loginShowRememberMe,
loginShowVersion: configCache.loginShowVersion,
demoMode: configCache.demoMode,
@@ -306,6 +309,7 @@ export function useConfig(): AppConfig {
loginShowSubtitle: data.loginShowSubtitle,
loginShowTotp: data.loginShowTotp,
loginShowTokenLogin: data.loginShowTokenLogin,
desktopBrowserSignIn: data.desktopBrowserSignIn,
loginShowRememberMe: data.loginShowRememberMe,
loginShowVersion: data.loginShowVersion,
demoMode: data.demoMode,
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { canEditFile, editAllowed, startAttachmentEdit, type EditStartResult, type StartEditInput } from "@/lib/desktop/edit-attachments";
import { deviceSubjectOf } from "@/lib/device-policy";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
const FAILURE_KEYS: Partial<Record<Exclude<EditStartResult, { ok: true }>["reason"], string>> = {
policy: "blocked_policy",
"no-app": "no_app",
type: "blocked_type",
"too-large": "too_large",
full: "too_many",
};
/** The local account a message or a draft belongs to, where a copy of its file is filed. */
export function editAccountOf(email: { sourceClientAccountId?: string | null } | null | undefined): string {
return deviceSubjectOf(email) ?? useAuthStore.getState().activeAccountId ?? "";
}
/**
* Edit, for whatever shows an attachment: whether to offer it, and starting it with the app's
* words for how it went. `account` is the local account the attachment belongs to; the
* hook follows that account's policy, so the item goes as soon as the answer turns to no.
*/
export function useEditAttachment(account: string) {
const t = useTranslations("desktopEdit");
const devices = useDevicePolicy(account || null);
const offered = !devices.noDownloads && !devices.noExport;
const canEdit = useCallback((name: string | null | undefined) => (
offered && !!account && canEditFile(name) && editAllowed(account)
), [offered, account]);
const edit = useCallback(async (input: StartEditInput): Promise<EditStartResult> => {
let result: EditStartResult;
try {
result = await startAttachmentEdit(input);
} catch (error) {
console.error("Could not open an attachment for editing:", error);
result = { ok: false, reason: "failed" };
}
if (result.ok) toast.success(t("opened", { name: input.name }));
// Locked: the lock screen is what's saying it.
// Ticket: a newer click took over, and that one speaks.
else if (result.reason !== "locked" && result.reason !== "ticket") toast.error(t(FAILURE_KEYS[result.reason] ?? "failed"));
return result;
}, [t]);
return { canEdit, edit };
}
+5
View File
@@ -66,6 +66,11 @@ export function usePhishingReport(emails: ReadonlyArray<Email | null | undefined
* in this session (a view that holds its own copy of the message, like a
* Pro tab, doesn't see the new keyword until it loads the message again).
*/
/** The same, once, for what isn't drawing a message: a forward taking its attachments along. */
export function phishingMarkedNow(email: Email | null | undefined): boolean {
return !!email && (isPhishingMarked(email) || usePhishingReportStore.getState().reported[emailKeyFor(email)] === true);
}
export function usePhishingMarked(email: Email | null | undefined): boolean {
const reported = usePhishingReportStore((s) => (email ? s.reported[emailKeyFor(email)] === true : false));
return isPhishingMarked(email) || reported;
+39 -18
View File
@@ -3,11 +3,12 @@
import { useEffect, useState } from "react";
import { IS_LITE } from "@/lib/lite";
import { apiFetch } from "@/lib/browser-navigation";
import { useAuthStore } from "@/stores/auth-store";
/**
* Whether a WOPI document editor is configured for this deployment and which
* file extensions it handles (#425). Fetched once per page load and shared
* across consumers via a module-level promise.
* file extensions it handles (#425). The answer is the same for every account,
* so the first real one is kept for the page load and shared across consumers.
*/
export interface WopiStatus {
enabled: boolean;
@@ -19,41 +20,61 @@ export interface WopiStatus {
const DISABLED: WopiStatus = { enabled: false, editExtensions: [], viewExtensions: [], newExtensions: [] };
let statusPromise: Promise<WopiStatus> | null = null;
let known: WopiStatus | null = null;
let pending: Promise<WopiStatus> | null = null;
let pendingClient: unknown = null;
function fetchWopiStatus(): Promise<WopiStatus> {
function fetchWopiStatus(client: unknown): Promise<WopiStatus> {
// The WOPI bridge is a server feature; the static build never has an editor.
if (IS_LITE) return Promise.resolve(DISABLED);
if (!statusPromise) {
statusPromise = apiFetch("/api/wopi/status")
.then((res) => (res.ok ? res.json() : DISABLED))
.then((data) => ({
if (known) return Promise.resolve(known);
// A request sent for an earlier client may be refused for lack of the sign-in
// context this one brought, so only the same client shares it.
if (pending && pendingClient === client) return pending;
const request: Promise<WopiStatus> = apiFetch("/api/wopi/status")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
known = {
enabled: !!data?.enabled,
editExtensions: Array.isArray(data?.editExtensions) ? data.editExtensions : [],
viewExtensions: Array.isArray(data?.viewExtensions) ? data.viewExtensions : [],
newExtensions: Array.isArray(data?.newExtensions) ? data.newExtensions : [],
}))
.catch(() => {
statusPromise = null; // allow a retry on the next mount
return DISABLED;
});
}
return statusPromise;
};
return known;
})
// A refusal or an outage says nothing about the editor (#1518): show no
// editor for now and ask again on the next mount or client.
.catch(() => DISABLED)
.finally(() => {
if (pending === request) pending = null;
});
pending = request;
pendingClient = client;
return request;
}
export function useWopiStatus(enabled: boolean): WopiStatus | null {
const [status, setStatus] = useState<WopiStatus | null>(null);
// The route needs the sign-in context cookie, which is written before the
// store holds a connected client. `isAuthenticated` alone comes back from
// storage on load, and a local copy's client gives way to the real one once
// the restore wrote the cookie, so this follows the client itself (#1518).
const client = useAuthStore((state) => (state.isAuthenticated ? state.client : null));
useEffect(() => {
if (!enabled) return;
if (!enabled || !client) return;
let cancelled = false;
fetchWopiStatus().then((s) => {
fetchWopiStatus(client).then((s) => {
if (!cancelled) setStatus(s);
});
return () => {
cancelled = true;
};
}, [enabled]);
}, [enabled, client]);
return status;
}
@@ -0,0 +1,469 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createHash } from 'crypto';
import { NextRequest } from 'next/server';
/**
* The desktop app's sign-in on an instance: what the browser checks and parks, and what the
* app's own page gets for the code. The real store, budgets and routes, with the mail server faked.
*/
const cookieJar = vi.hoisted(() => new Map<string, { value: string; options?: Record<string, unknown> }>());
vi.mock('next/headers', () => ({
cookies: async () => ({
get: (name: string) => (cookieJar.has(name) ? { name, value: cookieJar.get(name)!.value } : undefined),
set: (name: string, value: string, options?: Record<string, unknown>) => {
cookieJar.set(name, { value, options });
},
delete: (name: string) => {
cookieJar.delete(name);
},
getAll: () => [...cookieJar.entries()].map(([name, { value }]) => ({ name, value })),
}),
}));
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: (key: string, fallback: unknown) => (key in config.values ? config.values[key] : fallback),
},
}));
const verifyJmapAuth = vi.hoisted(() => vi.fn());
vi.mock('@/lib/auth/verify-jmap-auth', async (original) => ({
...(await original<object>()),
verifyJmapAuth,
}));
const stalwartPasswordLogin = vi.hoisted(() => vi.fn());
vi.mock('@/lib/auth/stalwart-password-login', async (original) => ({
...(await original<object>()),
stalwartPasswordLogin,
}));
const reserveLoginAttempt = vi.hoisted(() => vi.fn());
vi.mock('@/lib/auth/verify-budget', async (original) => ({
...(await original<object>()),
reserveLoginAttempt,
}));
const discoverOAuth = vi.hoisted(() => vi.fn());
vi.mock('@/lib/oauth/discovery', () => ({ discoverOAuth }));
vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } }));
const { POST: check } = await import('@/app/api/auth/desktop-handoff/check/route');
const { POST: park } = await import('@/app/api/auth/desktop-handoff/park/route');
const { POST: redeem } = await import('@/app/api/auth/desktop-handoff/redeem/route');
const { POST: discard } = await import('@/app/api/auth/desktop-handoff/discard/route');
const { POST: mobileRedeem } = await import('@/app/api/auth/handoff/redeem/route');
const { JmapAuthVerificationError } = await import('@/lib/auth/verify-jmap-auth');
const { HANDOFF_CONFIRM_TTL_MS, HANDOFF_TTL_MS, resetHandoffStoreForTests, stashHandoff } = await import('@/lib/auth/handoff-store');
const { desktopHandoffDeclineUrl, desktopHandoffPort, desktopHandoffTarget, mobileHandoffTarget } = await import('@/lib/auth/mobile-handoff');
const { isDesktopBlockedRoute } = await import('@/lib/desktop/blocked-routes');
const ORIGIN = 'https://bulwark.example';
const VERIFIER = 'v'.repeat(64);
const CHALLENGE = createHash('sha256').update(VERIFIER, 'ascii').digest('base64url');
const SERVER = 'https://mail.example';
const TOKENS = { ok: true, tokens: { access_token: 'access', refresh_token: 'refresh', expires_in: 3600 }, tokenEndpoint: `${SERVER}/auth/token`, clientId: 'x' };
/** A client of its own per test, so the budgets don't carry over: the real ones, unmocked. */
let clientAddress = '';
let nextClient = 1;
function post(path: string, body: unknown, origin = ORIGIN) {
return new NextRequest(`${ORIGIN}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: origin, Host: 'bulwark.example', 'x-forwarded-for': clientAddress },
body: JSON.stringify(body),
});
}
const parkBody = (extra: Record<string, unknown> = {}) => ({
code_challenge: CHALLENGE,
state: 'state-1',
server_url: SERVER,
username: 'a@example.org',
password: 'secret',
...extra,
});
const checkBody = (extra: Record<string, unknown> = {}) => ({ server_url: SERVER, username: 'a@example.org', password: 'secret', ...extra });
const parkIt = (extra: Record<string, unknown> = {}) => park(post('/api/auth/desktop-handoff/park', parkBody(extra)));
async function parkedCode(extra: Record<string, unknown> = {}): Promise<string> {
const answer = await parkIt(extra);
expect(answer.status).toBe(200);
return ((await answer.json()) as { code: string }).code;
}
const redeemBody = (code: string, extra: Record<string, unknown> = {}) => ({ code, code_verifier: VERIFIER, slot: 1, remember: true, allowed_servers: null, custom_servers_allowed: true, ...extra });
const redeemIt = (code: string, extra: Record<string, unknown> = {}) => redeem(post('/api/auth/desktop-handoff/redeem', redeemBody(code, extra)));
/** Everything the store holds, in the open. Nothing secret may be in it after its use. */
const heldInMemory = () => JSON.stringify([...((globalThis as unknown as { __bulwarkMobileHandoffStore?: Map<string, unknown> }).__bulwarkMobileHandoffStore ?? new Map()).values()]);
let revoked: string[];
beforeEach(() => {
cookieJar.clear();
resetHandoffStoreForTests();
clientAddress = `203.0.113.${(nextClient += 1)}`;
config.values = { jmapServerUrl: SERVER };
verifyJmapAuth.mockReset();
verifyJmapAuth.mockResolvedValue(undefined);
stalwartPasswordLogin.mockReset();
stalwartPasswordLogin.mockResolvedValue(TOKENS);
reserveLoginAttempt.mockReset();
reserveLoginAttempt.mockResolvedValue(async () => {});
discoverOAuth.mockReset();
discoverOAuth.mockResolvedValue({ revocation_endpoint: `${SERVER}/revoke` });
revoked = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (String(url).endsWith('/revoke')) revoked.push(new URLSearchParams(String(init?.body)).get('token') ?? '');
return new Response('{}', { status: 200 });
}));
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('the target', () => {
it('is only ever this computer on the one path', () => {
expect(desktopHandoffTarget('http://127.0.0.1:51234/bulwark-desktop/callback')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback');
for (const bad of [
'http://127.0.0.1/bulwark-desktop/callback',
'http://127.0.0.1:99999/bulwark-desktop/callback',
'http://localhost:51234/bulwark-desktop/callback',
'https://127.0.0.1:51234/bulwark-desktop/callback',
'http://127.0.0.1:51234/other',
'http://127.0.0.1:51234/bulwark-desktop/callback?x=1',
'http://127.0.0.1@evil.example:51234/bulwark-desktop/callback',
'http://evil.example/bulwark-desktop/callback',
'bulwarkmobile://auth/callback',
null,
]) {
expect(desktopHandoffTarget(bad), String(bad)).toBe('');
}
});
it('leaves the phone\'s rule as it was', () => {
expect(mobileHandoffTarget('http://127.0.0.1:51234/bulwark-desktop/callback')).toBe('');
});
it('a no goes back with the state and no code', () => {
const target = 'http://127.0.0.1:51234/bulwark-desktop/callback';
expect(desktopHandoffPort(target)).toBe('51234');
expect(desktopHandoffDeclineUrl(target, 's t')).toBe(`${target}?error=access_denied&state=s+t`);
});
it('the embedded build has none of these routes', () => {
for (const route of ['check', 'park', 'redeem', 'discard']) expect(isDesktopBlockedRoute(`/api/auth/desktop-handoff/${route}`)).toBe(true);
expect(isDesktopBlockedRoute('/api/auth/desktop-oauth')).toBe(false);
});
});
describe('checking, before the question', () => {
const checkIt = (extra: Record<string, unknown> = {}, origin = ORIGIN) => check(post('/api/auth/desktop-handoff/check', checkBody(extra), origin));
it('keeps nothing: no record, no cookie, and the password appears nowhere', async () => {
const answer = await checkIt();
expect(answer.status).toBe(200);
expect(heldInMemory()).toBe('[]');
expect([...cookieJar.keys()]).toEqual([]);
expect(stalwartPasswordLogin).not.toHaveBeenCalled();
});
it('refuses another origin', async () => {
expect((await checkIt({}, 'https://evil.example')).status).toBe(403);
expect(verifyJmapAuth).not.toHaveBeenCalled();
});
it('says a wrong password is wrong, and that a second factor is needed', async () => {
verifyJmapAuth.mockRejectedValueOnce(new JmapAuthVerificationError('no', 401, 401));
expect(await (await checkIt()).json()).toEqual({ error: 'invalid_credentials' });
verifyJmapAuth.mockRejectedValueOnce(new JmapAuthVerificationError('no', 401, 402));
expect(await (await checkIt()).json()).toEqual({ error: 'totp_required' });
});
it('holds no password on an OAuth-only instance', async () => {
config.values = { jmapServerUrl: SERVER, oauthEnabled: true, oauthOnly: true };
expect((await checkIt()).status).toBe(403);
expect(verifyJmapAuth).not.toHaveBeenCalled();
});
it('over the login budget, nothing reaches the mail server, and the answer says why', async () => {
reserveLoginAttempt.mockResolvedValue(null);
const answer = await checkIt();
expect(answer.status).toBe(429);
expect(await answer.json()).toEqual({ error: 'too_many_attempts' });
expect(verifyJmapAuth).not.toHaveBeenCalled();
});
it('never goes to a server the instance does not know', async () => {
expect((await checkIt({ server_url: 'https://evil.example' })).status).toBe(400);
expect(verifyJmapAuth).not.toHaveBeenCalled();
});
});
describe('the instance admin\'s switch', () => {
it('closes every route, with nothing checked, parked or redeemed', async () => {
const code = await parkedCode();
verifyJmapAuth.mockClear();
stalwartPasswordLogin.mockClear();
config.values = { jmapServerUrl: SERVER, desktopBrowserSignIn: false };
expect((await check(post('/api/auth/desktop-handoff/check', checkBody()))).status).toBe(403);
expect((await parkIt()).status).toBe(403);
expect(verifyJmapAuth).not.toHaveBeenCalled();
expect(stalwartPasswordLogin).not.toHaveBeenCalled();
expect((await redeemIt(code)).status).toBe(403);
});
});
describe('parking, after the yes', () => {
it('hands over tokens wherever the server can give them, and leaves this browser with no session', async () => {
const code = await parkedCode();
expect(verifyJmapAuth).not.toHaveBeenCalled();
expect([...cookieJar.keys()]).toEqual([]);
expect(heldInMemory()).not.toContain('secret');
const answer = await redeemIt(code);
const body = await answer.json();
expect(body).toMatchObject({ flow: 'tokens', password_login: true, username: 'a@example.org', access_token: 'access' });
expect(JSON.stringify(body)).not.toContain('secret');
expect(JSON.stringify(body)).not.toContain('"refresh"');
expect(cookieJar.get('jmap_rt_1')?.value).toBe('refresh');
});
it.each(['login_endpoint_missing', 'login_rejected', 'login_unreachable', 'token_exchange_failed'])('a password only as the last resort: %s', async (error) => {
stalwartPasswordLogin.mockResolvedValue({ ok: false, error });
const code = await parkedCode();
expect(verifyJmapAuth).toHaveBeenCalledOnce();
expect(await (await redeemIt(code)).json()).toEqual({ flow: 'password', server_url: SERVER, username: 'a@example.org', password: 'secret' });
});
it('a password that turns out wrong, on the way, parks nothing', async () => {
stalwartPasswordLogin.mockResolvedValue({ ok: false, error: 'invalid_credentials' });
const answer = await parkIt();
expect(answer.status).toBe(401);
expect(await answer.json()).toEqual({ error: 'invalid_credentials' });
expect(heldInMemory()).toBe('[]');
});
it('a wrong two-factor code gets no fallback to a password', async () => {
stalwartPasswordLogin.mockResolvedValue({ ok: false, error: 'login_rejected' });
expect((await parkIt({ totp: '000000' })).status).toBe(502);
expect(verifyJmapAuth).not.toHaveBeenCalled();
expect(heldInMemory()).toBe('[]');
});
it('refuses another origin', async () => {
expect((await park(post('/api/auth/desktop-handoff/park', parkBody(), 'https://evil.example'))).status).toBe(403);
expect(stalwartPasswordLogin).not.toHaveBeenCalled();
});
it.each([
['no challenge', { code_challenge: '' }],
['a bad challenge', { code_challenge: 'short' }],
['no state', { state: '' }],
['no password', { password: '' }],
])('refuses %s', async (_name, extra) => {
expect((await parkIt(extra)).status).toBe(400);
expect(stalwartPasswordLogin).not.toHaveBeenCalled();
});
it('what was checked is what is parked: the body can\'t name another server for the app', async () => {
config.values = { jmapServerUrl: SERVER, jmapServers: [{ id: 'two', url: 'https://two.example', name: 'Two' }] };
const code = await parkedCode({ server_id: 'two', server_url: 'https://evil.example' });
expect(stalwartPasswordLogin.mock.calls[0][0]).toMatchObject({ serverUrl: 'https://two.example' });
expect(await (await redeemIt(code)).json()).toMatchObject({ server_url: 'https://two.example' });
});
});
describe('the budget, with one address for a whole office', () => {
it('sign-ins that work never run out', async () => {
clientAddress = '198.51.100.7';
for (let i = 0; i < 25; i += 1) expect((await parkIt()).status, `sign-in ${i}`).toBe(200);
});
it('after ten failures it refuses before going upstream, so no code is spent, and says why', async () => {
clientAddress = '198.51.100.8';
stalwartPasswordLogin.mockResolvedValue({ ok: false, error: 'invalid_credentials' });
for (let i = 0; i < 10; i += 1) expect((await parkIt({ totp: '000000' })).status).toBe(401);
stalwartPasswordLogin.mockClear();
const answer = await parkIt({ totp: '123456' });
expect(answer.status).toBe(429);
expect(await answer.json()).toEqual({ error: 'too_many_attempts' });
expect(answer.headers.get('Retry-After')).toBeTruthy();
expect(stalwartPasswordLogin).not.toHaveBeenCalled();
});
it('a bad code to redeem is what its budget counts, and a good one gives it back', async () => {
clientAddress = '198.51.100.9';
for (let i = 0; i < 12; i += 1) expect((await redeemIt(await parkedCode())).status).toBe(200);
for (let i = 0; i < 10; i += 1) expect((await redeemIt('f'.repeat(64))).status).toBe(400);
expect((await redeemIt('f'.repeat(64))).status).toBe(429);
});
});
describe('redeeming, in the app', () => {
it('a code can be used once', async () => {
const code = await parkedCode();
expect((await redeemIt(code)).status).toBe(200);
const again = await redeemIt(code);
expect(again.status).toBe(400);
expect(await again.json()).toEqual({ error: 'invalid_code' });
});
it('a wrong verifier gets nothing, and spends the code', async () => {
const code = await parkedCode();
const wrong = await redeemIt(code, { code_verifier: 'w'.repeat(64) });
expect(wrong.status).toBe(400);
expect(await wrong.json()).toEqual({ error: 'invalid_verifier' });
expect((await redeemIt(code)).status).toBe(400);
expect([...cookieJar.keys()]).toEqual([]);
});
it('a late code is told so', async () => {
const code = await parkedCode();
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now + 61_000);
const answer = await redeemIt(code);
expect(answer.status).toBe(400);
expect(await answer.json()).toEqual({ error: 'expired_code' });
});
it('keeps the instance\'s strict cookies strict', async () => {
config.values = { jmapServerUrl: SERVER, cookieSameSite: 'strict' };
await redeemIt(await parkedCode());
expect(cookieJar.get('jmap_rt_1')?.options).toMatchObject({ sameSite: 'strict', httpOnly: true });
});
it('a server off the device\'s list gets nothing written, the token is revoked, and the code is gone', async () => {
const code = await parkedCode();
const answer = await redeemIt(code, { allowed_servers: ['https://other.example'] });
expect(answer.status).toBe(403);
expect(await answer.json()).toMatchObject({ code: 'blocked_by_policy' });
expect([...cookieJar.keys()]).toEqual([]);
expect(revoked).toEqual(['refresh']);
expect((await redeemIt(code)).status).toBe(400);
});
it('a list that names the server lets it through', async () => {
expect((await redeemIt(await parkedCode(), { allowed_servers: [SERVER] })).status).toBe(200);
});
describe('a server of the user\'s own, on an instance that allows them', () => {
const OWN = 'https://1.1.1.1';
beforeEach(() => {
config.values = { jmapServerUrl: SERVER, allowCustomJmapEndpoint: true };
});
it('is refused when the device\'s rule says no: forced InstanceUrl, or AllowStandalone off', async () => {
const code = await parkedCode({ server_url: OWN });
const answer = await redeemIt(code, { custom_servers_allowed: false });
expect(answer.status).toBe(403);
expect(await answer.json()).toMatchObject({ code: 'blocked_by_policy' });
expect([...cookieJar.keys()]).toEqual([]);
});
it('is refused when a page says nothing about it', async () => {
const code = await parkedCode({ server_url: OWN });
const { custom_servers_allowed: _left, ...withoutRule } = redeemBody(code);
expect((await redeem(post('/api/auth/desktop-handoff/redeem', withoutRule))).status).toBe(403);
});
it('goes through when the device allows it', async () => {
expect((await redeemIt(await parkedCode({ server_url: OWN }))).status).toBe(200);
});
it('the instance\'s own server is no such thing', async () => {
expect((await redeemIt(await parkedCode(), { custom_servers_allowed: false })).status).toBe(200);
});
});
it('refuses another origin', async () => {
const code = await parkedCode();
expect((await redeem(post('/api/auth/desktop-handoff/redeem', redeemBody(code), 'https://evil.example'))).status).toBe(403);
});
});
describe('what stays behind', () => {
const ssoBundle = {
flow: 'desktop-tokens' as const,
server_url: SERVER,
server_id: null,
custom: false,
access_token: 'sso-access',
refresh_token: 'sso-refresh',
expires_in: 3600,
signed_in_at: 1,
};
const parkedSso = () => stashHandoff(ssoBundle, { challenge: CHALLENGE, state: 's', awaitConfirm: true });
const discardIt = (code: string) => discard(post('/api/auth/desktop-handoff/discard', { code }));
it('"Not now" takes the tokens out of the store and revokes the refresh token', async () => {
const code = await parkedSso();
expect(heldInMemory()).toContain('sso-refresh');
expect((await discardIt(code)).status).toBe(200);
expect(heldInMemory()).not.toContain('sso-refresh');
expect(heldInMemory()).not.toContain('sso-access');
expect(revoked).toEqual(['sso-refresh']);
expect((await redeemIt(code)).status).toBe(400);
});
it('a provider with no revocation endpoint: the store is still emptied', async () => {
discoverOAuth.mockResolvedValue({});
const code = await parkedSso();
expect((await discardIt(code)).status).toBe(200);
expect(heldInMemory()).not.toContain('sso-refresh');
expect(revoked).toEqual([]);
});
it('discards only the desktop\'s, and nothing twice', async () => {
const phone = await stashHandoff({ flow: 'password', server_url: SERVER, username: 'a', password: 'phone-secret' }, { challenge: CHALLENGE, state: 's' });
await discardIt(phone);
expect(heldInMemory()).toContain('phone-secret');
const code = await parkedSso();
await discardIt(code);
await discardIt(code);
expect(revoked).toEqual(['sso-refresh']);
});
it('a code no one knows gets nothing', async () => {
expect((await discardIt('nonsense')).status).toBe(400);
});
it('with nobody coming back, a timer wipes what was parked, at its real expiry', async () => {
vi.useFakeTimers();
await stashHandoff({ flow: 'desktop-password', server_url: SERVER, server_id: null, custom: false, username: 'a', password: 'timer-secret' }, { challenge: CHALLENGE, state: 's' });
await parkedSso();
expect(heldInMemory()).toContain('timer-secret');
vi.advanceTimersByTime(HANDOFF_TTL_MS + 2);
expect(heldInMemory()).not.toContain('timer-secret');
expect(heldInMemory()).toContain('sso-refresh');
vi.advanceTimersByTime(HANDOFF_CONFIRM_TTL_MS);
expect(heldInMemory()).not.toContain('sso-refresh');
});
});
describe('the two kinds of code stay apart', () => {
it('the phone\'s redeem gives away nothing of a desktop sign-in', async () => {
const code = await parkedCode();
const answer = await mobileRedeem(post('/api/auth/handoff/redeem', { code, code_verifier: VERIFIER }));
expect(answer.status).toBe(400);
const body = JSON.stringify(await answer.json());
expect(body).not.toContain('refresh');
expect(body).not.toContain('access');
});
it('the desktop redeem takes no phone code', async () => {
const code = await stashHandoff({ flow: 'password', server_url: SERVER, username: 'a', password: 'secret' }, { challenge: CHALLENGE, state: 's' });
const answer = await redeemIt(code);
expect(answer.status).toBe(400);
expect(JSON.stringify(await answer.json())).not.toContain('secret');
});
});
+1
View File
@@ -32,6 +32,7 @@ export function createConfig(overrides: Partial<ConfigData> = {}): ConfigData {
loginShowSubtitle: true,
loginShowTotp: true,
loginShowTokenLogin: false,
desktopBrowserSignIn: true,
loginShowRememberMe: false,
loginShowVersion: true,
demoMode: false,
+2 -2
View File
@@ -5,7 +5,7 @@ import vm from 'node:vm';
export async function pushClick(
accountId: string,
preview: (url: string) => Promise<unknown>,
options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string; noParamsSize?: boolean } = {},
options: { warm?: boolean; basePath?: string; legacy?: boolean; emailId?: string; noParamsSize?: boolean; onScreen?: string[] } = {},
): Promise<string> {
const source = readFileSync(resolve('public/sw.js'), 'utf8');
let notification: { data: unknown } | undefined;
@@ -33,7 +33,7 @@ export async function pushClick(
},
registration: {
showNotification: async (_title: string, data: { data: unknown }) => { notification = data; },
getNotifications: async () => [],
getNotifications: async () => (options.onScreen?.length ? [{ data: { ids: options.onScreen } }] : []),
},
},
caches: { open: async () => ({
+8
View File
@@ -47,6 +47,14 @@ describe('oauth/discovery', () => {
}
});
it('keeps a registration endpoint the server names, for the desktop app, and only a string', async () => {
const answer = (extra: Record<string, unknown>) => ({ status: 200, ok: true, headers: new Headers(), json: async () => ({ ...VALID_METADATA, ...extra }) });
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(answer({ registration_endpoint: 'https://auth.example.com/register' })));
expect((await discoverOAuth('https://a.example.com', { validateEndpoint }))?.registration_endpoint).toBe('https://auth.example.com/register');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(answer({ registration_endpoint: 7 })));
expect((await discoverOAuth('https://b.example.com', { validateEndpoint }))?.registration_endpoint).toBeUndefined();
});
it('follows one redirect that stays on the same host', async () => {
const redirect = { status: 301, ok: false, headers: new Headers({ location: '/auth/.well-known/openid-configuration' }), json: async () => ({}) };
const document = { status: 200, ok: true, headers: new Headers(), json: async () => VALID_METADATA };
+88
View File
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// The context cookie lasts as long as the browser session. With every window
// closed and the browser started again by a push, only the remembered session
// is left, and the worker has to make do with that or show "New mail".
vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } }));
const maxHours = vi.hoisted(() => ({ value: 0 }));
vi.mock('@/lib/auth/session-age', () => ({
isPastMaxAge: (signedInAt: number, hours: number) => hours > 0 && Date.now() - signedInAt > hours * 3600_000,
limitsEnforcedSince: async () => Date.now(),
maxSessionHoursFor: async () => maxHours.value,
}));
const impersonation = vi.hoisted(() => ({ master: null as string | null }));
const policy = vi.hoisted(() => ({ value: {} as Record<string, unknown> }));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { ensureLoaded: async () => {}, getPolicy: () => policy.value },
}));
vi.mock('@/lib/impersonation/master-config', () => ({
readImpersonationConfig: () => (impersonation.master ? { masterPassword: impersonation.master } : null),
}));
import { encryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readPushAuthContext } from '@/lib/stalwart/push-auth-context';
import { stalwartAuthContextCookieName } from '@/lib/stalwart/auth-context';
import { encryptPayload } from '@/lib/auth/crypto';
function store(cookies: Record<string, string>) {
return { get: (name: string) => (name in cookies ? { value: cookies[name] } : undefined) } as never;
}
const remembered = (signedInAt = Date.now(), password = 'pw') => encryptSession('https://mail.example.org', 'me@example.org', password, signedInAt);
beforeEach(() => {
process.env.SESSION_SECRET = 'a-test-secret-that-is-long-enough-for-this-0123456789';
maxHours.value = 0;
impersonation.master = null;
policy.value = {};
});
describe('readPushAuthContext', () => {
it('prefers the context cookie', async () => {
const context = encryptPayload({ serverUrl: 'https://ctx.example.org', username: 'ctx@example.org', authHeader: 'Bearer t' }, 'session-context');
const found = await readPushAuthContext(store({ [stalwartAuthContextCookieName(0)]: context, [sessionCookieName(0)]: remembered() }), 0);
expect(found?.username).toBe('ctx@example.org');
});
it('falls back to the remembered session', async () => {
const found = await readPushAuthContext(store({ [sessionCookieName(1)]: remembered() }), 1);
expect(found).toMatchObject({ serverUrl: 'https://mail.example.org', username: 'me@example.org' });
expect(found?.authHeader).toMatch(/^Basic /);
});
it('finds nothing in another slot, or without either cookie', async () => {
expect(await readPushAuthContext(store({ [sessionCookieName(1)]: remembered() }), 0)).toBeNull();
expect(await readPushAuthContext(store({}), 0)).toBeNull();
});
it('refuses a session past the organisation\'s maximum length', async () => {
maxHours.value = 1;
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered(Date.now() - 2 * 3600_000) }), 0)).toBeNull();
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered() }), 0)).not.toBeNull();
});
it('stays out when the organisation has limits only a page enforces', async () => {
const cookies = store({ [sessionCookieName(0)]: remembered() });
policy.value = { organisation: { session: { idleMinutes: 30 } } };
expect(await readPushAuthContext(cookies, 0)).toBeNull();
policy.value = { scopes: [{ match: { domains: ['example.org'] }, policy: { organisation: { session: { idleMinutes: 5 } } } }] };
expect(await readPushAuthContext(cookies, 0)).toBeNull();
policy.value = { scopes: [{ match: { groups: ['staff@example.org'] }, policy: { organisation: { session: { maxHours: 8 } } } }] };
expect(await readPushAuthContext(cookies, 0)).toBeNull();
// A maximum by domain the server checks itself, above.
policy.value = { scopes: [{ match: { domains: ['example.org'] }, policy: { organisation: { session: { maxHours: 8 } } } }] };
expect(await readPushAuthContext(cookies, 0)).not.toBeNull();
});
it('stays out for a bare user name, which policies for a domain cannot match', async () => {
const token = encryptSession('https://mail.example.org', 'john', 'pw', Date.now());
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: token }), 0)).toBeNull();
});
it('refuses garbage and a cookie holding the impersonation master password', async () => {
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: 'garbage' }), 0)).toBeNull();
impersonation.master = 'master-secret';
expect(await readPushAuthContext(store({ [sessionCookieName(0)]: remembered(Date.now(), 'master-secret') }), 0)).toBeNull();
});
});
+20 -1
View File
@@ -3,6 +3,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The page mirrors the notification sound setting into the service worker's
// Cache store, where sw.js reads it before showing a push notification (#1289).
describe('writeLoginQuiet', () => {
it('keeps one answer per login', async () => {
const stored = new Map<string, string>();
vi.stubGlobal('caches', {
open: async () => ({
match: async (key: string) => (stored.has(key) ? new Response(stored.get(key)) : undefined),
put: async (key: string, res: Response) => { stored.set(key, await res.text()); },
}),
});
const { writeLoginQuiet } = await import('../push-preferences');
await writeLoginQuiet('a@example.org', true);
await writeLoginQuiet('b@example.org', false);
await writeLoginQuiet('a@example.org', true);
const value = JSON.parse(stored.get(`${window.location.origin}/__push-state/quiet-logins`) ?? 'null');
expect(value).toEqual({ 'a@example.org': true, 'b@example.org': false });
vi.unstubAllGlobals();
});
});
describe('writePushPreferences', () => {
const put = vi.fn();
@@ -65,7 +84,7 @@ describe('settings store push preference mirror', () => {
useSettingsStore.getState().updateSetting('emailNotificationsEnabled', true);
expect(writePushPreferences).not.toHaveBeenCalled();
// Email notifications off means no sound for push either.
// Email notifications off means no notification, so no sound either.
useSettingsStore.getState().updateSetting('emailNotificationSound', true);
useSettingsStore.getState().updateSetting('emailNotificationsEnabled', false);
expect(writePushPreferences).toHaveBeenLastCalledWith({ sound: false });
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { beforeAll, describe, it, expect, vi, beforeEach } from 'vitest';
// The push names only the JMAP account id, and ids repeat: Stalwart numbers
// accounts alike on every install, and a group account is listed in the
@@ -26,13 +26,18 @@ vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 3 }));
// slot 0: alice on one.example, slot 1: bob on two.example, slot 2: carol on one.example
const LOGINS = [
{ serverUrl: 'https://one.example', authHeader: 'Basic alice' },
{ serverUrl: 'https://two.example', authHeader: 'Basic bob' },
{ serverUrl: 'https://one.example', authHeader: 'Basic carol' },
{ serverUrl: 'https://one.example', authHeader: 'Basic alice', username: 'alice@one.example' },
{ serverUrl: 'https://two.example', authHeader: 'Basic bob', username: 'bob@two.example' },
{ serverUrl: 'https://one.example', authHeader: 'Basic carol', username: 'carol@one.example' },
];
const ORIGINAL_LOGINS = [...LOGINS];
vi.mock('@/lib/stalwart/auth-context', () => ({
readStalwartAuthContextFromStore: (_store: unknown, slot: number) => LOGINS[slot] ?? null,
}));
const remembered = vi.hoisted(() => ({ value: null as null | { serverUrl: string; authHeader: string; username: string } }));
vi.mock('@/lib/stalwart/push-auth-context', () => ({
readPushAuthContext: async (_store: unknown, slot: number) => LOGINS[slot] ?? (slot === 0 ? remembered.value : null),
}));
vi.mock('@/lib/stalwart/credentials', () => ({
getStalwartCredentials: vi.fn(),
@@ -73,10 +78,10 @@ vi.mock('@/lib/admin/config-manager', async () => {
return { configManager: { ensureLoaded: async () => {}, getPolicy: () => DEFAULT_POLICY } };
});
async function callRoute(accountId: string) {
async function callRoute(accountId: string, login?: string) {
const { GET } = await import('@/app/api/push/preview/route');
const request = {
nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? 'e1' : null) } },
nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? 'e1' : k === 'login' ? login ?? null : null) } },
headers: new Headers(),
};
const res = (await GET(request as unknown as Parameters<typeof GET>[0])) as unknown as {
@@ -88,11 +93,86 @@ async function callRoute(accountId: string) {
const mailCalls = () => fetchJmapServer.mock.calls.filter(([url]) => !url.endsWith('/.well-known/jmap'));
// The route's first import can take longer than a test's limit on a busy machine,
// and a test that times out keeps running into the next one's state.
beforeAll(async () => {
await import('@/app/api/push/preview/route');
}, 60_000);
describe('push preview after the browser was closed', () => {
it('reads the login from the remembered session when the context cookie is gone', async () => {
remembered.value = { serverUrl: 'https://one.example', authHeader: 'Basic remembered', username: 'alice@one.example' };
LOGINS.length = 0;
try {
SESSIONS = { 'Basic remembered': session('https://one.example', 'a') };
const { status, body } = await callRoute('a');
expect(status).toBe(200);
expect((body.email as { subject: string }).subject).toBe('mail of Basic remembered');
} finally {
remembered.value = null;
LOGINS.push(...ORIGINAL_LOGINS);
}
});
});
describe('push preview when the account id is in more than one login', () => {
beforeEach(() => {
fetchJmapServer.mockClear();
});
it('does not go by the name when two servers use the same id: those logins share one relay record', async () => {
SESSIONS = {
'Basic alice': session('https://one.example', 'a'),
'Basic bob': session('https://two.example', 'z'),
'Basic carol': session('https://one.example', 'z'),
};
const { status, body } = await callRoute('z', 'BOB@two.example');
expect(status).toBe(409);
expect(body.slot).toBeUndefined();
});
it('does not answer for another login when the one the push names is gone', async () => {
SESSIONS = {
'Basic alice': session('https://one.example', 'a'),
'Basic bob': session('https://two.example', 'z'),
'Basic carol': session('https://one.example', 'c'),
};
const { status } = await callRoute('z', 'somebody-gone@two.example');
expect(status).toBe(409);
});
it('takes the one login with the account when the push names it', async () => {
SESSIONS = {
'Basic alice': session('https://one.example', 'a'),
'Basic bob': session('https://two.example', 'z'),
'Basic carol': session('https://one.example', 'c'),
};
const { status, body } = await callRoute('z', 'bob@two.example');
expect(status).toBe(200);
expect(body.slot).toBe(1);
});
it('takes the login the push names for a group two logins belong to', async () => {
SESSIONS = {
'Basic alice': session('https://one.example', 'a', ['team']),
'Basic bob': session('https://two.example', 'b'),
'Basic carol': session('https://one.example', 'c', ['team']),
};
const { status, body } = await callRoute('team', 'carol@one.example');
expect(status).toBe(200);
expect(body.slot).toBe(2);
});
it('does not take a login that does not have the account', async () => {
SESSIONS = {
'Basic alice': session('https://one.example', 'a', ['team']),
'Basic bob': session('https://two.example', 'b'),
'Basic carol': session('https://one.example', 'c', ['team']),
};
const { status } = await callRoute('team', 'bob@two.example');
expect(status).toBe(409);
});
it('shows no preview and names no login when two servers use the same id', async () => {
SESSIONS = {
'Basic alice': session('https://one.example', 'a'),
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { forgetRelayRegistration, keepRelayRegistration, relayRegistrationsKey } from '@/lib/pwa-notify/worker-state';
const store = new Map<string, string>();
beforeEach(() => {
store.clear();
vi.stubGlobal('caches', {
open: async () => ({
match: async (key: string) => (store.has(key) ? new Response(store.get(key)) : undefined),
put: async (key: string, res: Response) => { store.set(key, await res.text()); },
}),
});
});
const stored = () => JSON.parse(store.get(relayRegistrationsKey(window.location.origin)) ?? 'null');
describe('what the worker keeps to re-register without a tab', () => {
it('keeps one entry per account and the key', async () => {
await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, 'KEY');
await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY');
expect(stored()).toEqual({
applicationServerKey: 'KEY',
entries: { a: { relay: 'https://r', subscriptionId: 'd1', accountLabel: 'a@x' }, b: { relay: 'https://r', subscriptionId: 'd2' } },
});
});
it('forgets an account, and leaves the others', async () => {
await keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY');
await keepRelayRegistration('b', { relay: 'https://r', subscriptionId: 'd2' }, 'KEY');
await forgetRelayRegistration('a');
expect(Object.keys(stored().entries)).toEqual(['b']);
});
it('stays out of the way without a Cache API', async () => {
vi.stubGlobal('caches', undefined);
await expect(keepRelayRegistration('a', { relay: 'https://r', subscriptionId: 'd1' }, 'KEY')).resolves.toBeUndefined();
await expect(forgetRelayRegistration('a')).resolves.toBeUndefined();
});
});
@@ -109,3 +109,43 @@ describe('mobile hand-off with a shared store', () => {
expect(await (await replica()).redeemHandoff(code, VERIFIER, T0 + 2)).toMatchObject({ ok: true });
});
});
describe('what a shared store holds after a sign-in stops being any use', () => {
const key = (code: string) => 'handoff:' + createHash('sha256').update(code).digest('hex');
const DESKTOP = { flow: 'desktop-tokens' as const, server_url: 'https://mail.example.org', server_id: null, custom: false, access_token: 'a', refresh_token: 'r', expires_in: 60, signed_in_at: 1 };
it('a record with a sign-in in it is gone from the store at its real expiry, and a late code is still told so', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(T0);
const { stashHandoff, redeemHandoff, HANDOFF_TTL_MS } = await replica();
const code = await stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's' }, T0);
expect(await getStore().kv.get(key(code))).not.toBeNull();
vi.setSystemTime(T0 + HANDOFF_TTL_MS + 1);
expect(await getStore().kv.get(key(code))).toBeNull();
expect(await redeemHandoff(code, VERIFIER, T0 + HANDOFF_TTL_MS + 1)).toEqual({ ok: false, reason: 'expired' });
vi.useRealTimers();
});
it('a confirmed code stays no longer than its 60 seconds', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(T0);
const { stashHandoff, confirmHandoff, HANDOFF_TTL_MS } = await replica();
const code = await stashHandoff(DESKTOP, { challenge: CHALLENGE, state: 's', awaitConfirm: true }, T0);
expect(await confirmHandoff(code, T0)).toBe(true);
vi.setSystemTime(T0 + HANDOFF_TTL_MS + 1);
expect(await getStore().kv.get(key(code))).toBeNull();
vi.useRealTimers();
});
it('a discard takes the sign-in out at once, gives it back once, and only the desktop\'s', async () => {
const { stashHandoff, discardDesktopHandoff, redeemHandoff } = await replica();
const code = await stashHandoff(DESKTOP, { challenge: CHALLENGE, state: 's', awaitConfirm: true });
const other = await replica();
expect(await other.discardDesktopHandoff(code)).toMatchObject({ refresh_token: 'r' });
expect(await discardDesktopHandoff(code)).toBeNull();
expect((await redeemHandoff(code, VERIFIER)).ok).toBe(false);
const phone = await stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's' });
expect(await discardDesktopHandoff(phone)).toBeNull();
expect((await redeemHandoff(phone, VERIFIER)).ok).toBe(true);
});
});
+59
View File
@@ -224,3 +224,62 @@ describe('sso/complete mobile hand-off while the shared store is down (#1066)',
expect(JSON.stringify(res.body)).not.toMatch(/"at"|"rt"|mobile_handoff_code/);
});
});
describe('/api/auth/sso/complete for the desktop app', () => {
const VERIFIER = 'x'.repeat(50);
const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url');
const LISTENER = 'http://127.0.0.1:51234/bulwark-desktop/callback';
it('parks the tokens under a code, writes nothing in this browser, and only a confirmed code redeems', async () => {
exchangeCodeForTokens.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', id_token: 'idt', expires_in: 3600 });
pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE });
const res = await complete();
expect(res.status).toBe(200);
expect(res.body).toEqual({
mobile_handoff_code: expect.stringMatching(/^[0-9a-f]{64}$/),
server_url: 'https://mail.example.org',
account: '',
mobile_redirect_uri: LISTENER,
mobile_state: 'd-1',
});
expect(JSON.stringify(res.body)).not.toMatch(/"at"|"rt"|idt/);
expect(jar.has('jmap_rt')).toBe(false);
expect(jar.has('sso_pending')).toBe(false);
expect((await redeemHandoff(res.body.mobile_handoff_code, VERIFIER)).ok).toBe(false);
});
it('names the account for the question: from the id token, or the server\'s session', async () => {
const jwt = (claims: object) => `x.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.y`;
exchangeCodeForTokens.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', id_token: jwt({ email: 'alice@example.org' }), expires_in: 3600 });
pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE });
expect((await complete()).body.account).toBe('alice@example.org');
const fetched = vi.fn(async () => new Response(JSON.stringify({ username: 'bob@example.org' }), { status: 200 }));
vi.stubGlobal('fetch', fetched);
exchangeCodeForTokens.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 });
pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE });
expect((await complete()).body.account).toBe('bob@example.org');
expect(fetched).toHaveBeenCalledWith('https://mail.example.org/.well-known/jmap', expect.objectContaining({ headers: { Authorization: 'Bearer at' } }));
vi.unstubAllGlobals();
});
it('hands the refresh token itself to the app, which writes its own cookie', async () => {
exchangeCodeForTokens.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', id_token: 'idt', expires_in: 3600 });
pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE, server_id: 'second' });
const res = await complete();
expect(await confirmHandoff(res.body.mobile_handoff_code)).toBe(true);
expect(await redeemHandoff(res.body.mobile_handoff_code, VERIFIER)).toMatchObject({
ok: true,
state: 'd-1',
bundle: { flow: 'desktop-tokens', server_id: 'second', access_token: 'at', refresh_token: 'rt', id_token: 'idt' },
});
});
it('fails rather than hands over tokens if a pending cookie has no challenge', async () => {
pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1' });
const res = await complete();
expect(res.status).toBe(401);
expect(JSON.stringify(res.body)).not.toMatch(/"at"|"rt"/);
});
});
+43 -1
View File
@@ -66,8 +66,9 @@ vi.mock('@/lib/auth/session-secret', () => ({
hasSessionSecret: () => true,
}));
const configValues = vi.hoisted(() => ({ desktopBrowserSignIn: true } as Record<string, unknown>));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { get: () => '' },
configManager: { get: (key: string) => (key in configValues ? configValues[key] : '') },
}));
function mockRequest(body: Record<string, unknown>): unknown {
@@ -196,3 +197,44 @@ describe('sso start for the mobile app', () => {
expect(encrypted.last).not.toHaveProperty('mobile_code_challenge');
});
});
describe('sso start for the desktop app', () => {
const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
const LISTENER = 'http://127.0.0.1:51234/bulwark-desktop/callback';
const desktop = (extra: Record<string, unknown> = {}) => callRoute({
redirect_uri: 'https://mail.example.com/auth/callback',
mobile_redirect_uri: LISTENER,
mobile_state: 'd-1',
...extra,
});
beforeEach(() => {
encrypted.last = null;
});
it('keeps the loopback target and its challenge for the complete route', async () => {
const { status } = await desktop({ mobile_code_challenge: CHALLENGE });
expect(status).toBe(200);
expect(encrypted.last).toMatchObject({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE });
});
it('refuses it when the instance\'s admin turned it off', async () => {
configValues.desktopBrowserSignIn = false;
const { status } = await desktop({ mobile_code_challenge: CHALLENGE });
configValues.desktopBrowserSignIn = true;
expect(status).toBe(403);
expect(encrypted.last).toBeNull();
});
it('refuses it without a challenge: tokens would come back for a link', async () => {
const { status } = await desktop();
expect(status).toBe(400);
expect(encrypted.last).toBeNull();
});
it.each(['http://127.0.0.1:51234/other', 'http://localhost:51234/bulwark-desktop/callback', 'http://evil.example/bulwark-desktop/callback'])('refuses %s as a target', async (target) => {
const { status } = await desktop({ mobile_code_challenge: CHALLENGE, mobile_redirect_uri: target });
expect(status).toBe(400);
expect(encrypted.last).toBeNull();
});
});
+31 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment node
import { afterEach, describe, expect, it } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
@@ -120,6 +120,36 @@ describe('standalone guard check', () => {
expect(findForbidden(dir)).toEqual(['worktrees/webmail/data/']);
});
it('goes by the name in any case, as a case-insensitive disk and Docker context would', () => {
const dir = tempDir();
put(dir, 'server.js');
put(dir, '.ENV', 'SECRET=1');
put(dir, 'Plugin-Signing.KEY', 'SECRET=1');
put(dir, 'Data/admin.json');
put(dir, 'LOCAL-DATA/a');
put(dir, 'Repos/a');
expect(findForbidden(dir)).toEqual(['.ENV', 'Data/', 'LOCAL-DATA/', 'Plugin-Signing.KEY', 'Repos/']);
});
it('refuses a link: it would ship whatever it points at', () => {
const dir = tempDir();
const outside = tempDir();
put(outside, 'admin.json');
put(dir, 'server.js');
symlinkSync(outside, path.join(dir, 'state'), 'junction');
expect(findForbidden(dir)).toEqual(['state (link)']);
});
it('leaves the links of .next and node_modules alone: Turbopack makes those on purpose', () => {
const dir = tempDir();
const outside = tempDir();
put(dir, 'node_modules/a/index.js');
mkdirSync(path.join(dir, '.next', 'node_modules'), { recursive: true });
symlinkSync(path.join(dir, 'node_modules', 'a'), path.join(dir, '.next', 'node_modules', 'a-1234'), 'junction');
symlinkSync(outside, path.join(dir, 'node_modules', 'b'), 'junction');
expect(findForbidden(dir)).toEqual([]);
});
it('removes the .env files Next copies next to server.js, then passes', () => {
const dir = tempDir();
put(dir, 'worktrees/webmail/server.js');
@@ -90,12 +90,13 @@ const previewOf = (unreadTotal: number) => JSON.stringify({
});
describe('the buttons a notification gets', () => {
const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number }) => {
const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number; onScreen?: string[] }) => {
const w = worker({
caches: await cachesWith(opts.prefs),
max: opts.max,
inline: opts.inline,
fetch: async () => new Response(previewOf(opts.unreadTotal ?? 1), { status: 200 }),
...(opts.onScreen ? { notifications: [{ data: { ids: opts.onScreen } }] } : {}),
});
await pushFor({})(w);
expect(w.shown).toHaveLength(1);
@@ -133,7 +134,7 @@ describe('the buttons a notification gets', () => {
});
it('gives the group notification none: it names no message', async () => {
expect(await buttonsFor({ prefs: PREFS, unreadTotal: 4 })).toBeUndefined();
expect(await buttonsFor({ prefs: PREFS, onScreen: ['e0'] })).toBeUndefined();
});
it('gives a message without its login none', async () => {
+320
View File
@@ -0,0 +1,320 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
// What one new message, several at once and several accounts turn into on screen.
const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
type Preview = Record<string, unknown> | null;
type Shown = { title: string; body?: unknown; data?: Record<string, unknown>; tag: string; options: Record<string, unknown>; close: () => void };
function loadWorker(preview: (url: URL) => Preview, extra: { clients?: unknown[]; globals?: Record<string, unknown>; prefs?: unknown; quiet?: Record<string, boolean>; openWindow?: (url: string) => Promise<unknown>; registration?: Record<string, unknown> } = {}) {
const listeners = new Map<string, (event: unknown) => void>();
const shade = new Map<string, Shown>();
const previews: URL[] = [];
const cacheStore = new Map<string, string>();
if (extra.prefs) cacheStore.set('https://mail.example.com/__push-state/prefs', JSON.stringify(extra.prefs));
if (extra.quiet) cacheStore.set('https://mail.example.com/__push-state/quiet-logins', JSON.stringify(extra.quiet));
const self = {
location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' },
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
skipWaiting: () => {},
clients: { claim: () => {}, matchAll: async () => extra.clients ?? [], openWindow: extra.openWindow },
registration: {
showNotification: async (title: string, options: Record<string, unknown>) => {
const tag = String(options.tag ?? '');
shade.set(tag, { title, body: options.body, data: options.data as Record<string, unknown>, tag, options, close: () => shade.delete(tag) });
},
getNotifications: async (filter?: { tag?: string }) => [...shade.values()].filter((n) => !filter?.tag || n.tag === filter.tag),
...extra.registration,
},
};
const caches = {
open: async () => ({
match: async (key: string) => {
const value = cacheStore.get(key);
return value === undefined ? undefined : { json: async () => JSON.parse(value) };
},
put: async (key: string, res: { text: () => Promise<string> }) => { cacheStore.set(key, await res.text()); },
}),
};
const fetchMock = vi.fn(async (input: string) => {
const url = new URL(input, 'https://mail.example.com');
previews.push(url);
const body = preview(url);
return body ? { ok: true, json: async () => body } : { ok: false, status: 401, json: async () => ({}) };
});
const context = vm.createContext({ self, caches, fetch: fetchMock, URL, URLSearchParams, Response, Date, console, AbortController, setTimeout, clearTimeout, ...extra.globals });
vm.runInContext(SW_SOURCE, context);
async function dispatch(type: string, event: Record<string, unknown>) {
const waits: Promise<unknown>[] = [];
listeners.get(type)?.({ ...event, waitUntil: (p: Promise<unknown>) => waits.push(p) });
await Promise.all(waits);
}
const push = (payload: unknown) => dispatch('push', { data: { json: () => payload } });
return { push, dispatch, shade, previews };
}
const mail = (id: string, subject = 'Hello') => ({ id, threadId: `t-${id}`, from: [{ name: 'Alice' }], subject, preview: '' });
const delivery = (accountId: string, ...emailIds: string[]) => ({ kind: 'jmap-email-push', accountLabel: 'me@example.org', accountId, emailIds });
afterEach(() => vi.useRealTimers());
describe('a state-change push naming several accounts', () => {
const stateChange = (changed: Record<string, Record<string, string>>, accountId = Object.keys(changed)[0]) => ({
kind: 'jmap-state-change', accountLabel: 'me@example.org', accountId, emailIds: [], changed,
});
it('notifies for each account with new mail', async () => {
const { push, shade, previews } = loadWorker((url) => ({ unreadTotal: 1, slot: 0, email: mail(`m-${url.searchParams.get('accountId')}`) }));
await push(stateChange({ me: { EmailDelivery: 's1', Email: 's1' }, team: { EmailDelivery: 's2' } }));
expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me', 'team']);
expect([...shade.keys()].sort()).toEqual(['bulwark-mail:me', 'bulwark-mail:team']);
expect(shade.get('bulwark-mail:team')?.data).toMatchObject({ accountId: 'team', emailId: 'm-team' });
});
it('leaves out an account that only changed some other way', async () => {
const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }));
await push(stateChange({ quiet: { Mailbox: 's1' }, me: { EmailDelivery: 's2' } }));
expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me']);
});
it('still shows something for a ping without new mail in it', async () => {
const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }));
await push(stateChange({ me: { Mailbox: 's1' } }));
expect(previews.map((url) => url.searchParams.get('accountId'))).toEqual(['me']);
});
});
describe('what a notification counts', () => {
it('opens the one new message, however much older mail is unread', async () => {
const { push, shade } = loadWorker(() => ({ unreadTotal: 40, slot: 0, login: 'fp', email: mail('e1') }));
await push(delivery('a', 'e1'));
const shown = shade.get('bulwark-mail:a')!;
expect(shown.body).toBe('Hello');
expect(shown.data).toMatchObject({ kind: 'email', emailId: 'e1', accountId: 'a', slot: 0, login: 'fp' });
});
it('counts the messages it announced, not the unread mail, and opens the inbox for a group', async () => {
const { push, shade } = loadWorker((url) => ({ unreadTotal: 40, slot: 0, email: mail(String(url.searchParams.get('emailId')), `About ${url.searchParams.get('emailId')}`) }));
await push(delivery('a', 'e1'));
await push(delivery('a', 'e2'));
let shown = shade.get('bulwark-mail:a')!;
expect(shown.body).toBe('About e2\n+1 more message');
expect(shown.data).toMatchObject({ kind: 'mail-list', accountId: 'a', slot: 0 });
await push(delivery('a', 'e3', 'e4'));
shown = shade.get('bulwark-mail:a')!;
expect(shown.body).toBe('About e4\n+3 more messages');
});
it('starts over once the notification was dismissed', async () => {
const { push, shade } = loadWorker((url) => ({ unreadTotal: 2, slot: 0, email: mail(String(url.searchParams.get('emailId'))) }));
await push(delivery('a', 'e1'));
shade.get('bulwark-mail:a')!.close();
await push(delivery('a', 'e2'));
expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e2' });
});
it('does not count a redelivered message twice', async () => {
const { push, shade } = loadWorker((url) => ({ unreadTotal: 2, slot: 0, email: mail(String(url.searchParams.get('emailId'))) }));
await push(delivery('a', 'e1'));
await push(delivery('a', 'e1'));
expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e1' });
expect(shade.get('bulwark-mail:a')!.options).toMatchObject({ silent: true });
});
it('counts on the state-change path too', async () => {
let id = 'e1';
const { push, shade } = loadWorker(() => ({ unreadTotal: 5, slot: 0, email: mail(id) }));
await push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } });
expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e1' });
id = 'e2';
await push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's2' } } });
expect(shade.get('bulwark-mail:a')!.body).toBe('Hello\n+1 more message');
});
it('says how many new messages without naming any when details are hidden', async () => {
const { push, shade } = loadWorker((url) => ({ unreadTotal: 9, slot: 0, redacted: true, email: { id: url.searchParams.get('emailId'), threadId: 't' } }));
await push(delivery('a', 'e1'));
expect(shade.get('bulwark-mail:a')!.body).toBe('You have new mail');
expect(shade.get('bulwark-mail:a')!.data).toMatchObject({ kind: 'email', emailId: 'e1' });
await push(delivery('a', 'e2'));
expect(shade.get('bulwark-mail:a')!.title).toBe('New mail (me@example.org)');
expect(shade.get('bulwark-mail:a')!.body).toBe('2 new messages');
});
});
describe('with notifications off or do not disturb on', () => {
const quiet = { 'me@example.org': true };
it('announces nothing for that login: shows and removes at once, without asking for the message', async () => {
const { push, shade, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1', 'Secret subject') }), { quiet });
await push(delivery('a', 'e1'));
expect(shade.size).toBe(0);
expect(previews).toHaveLength(0);
});
it('still announces mail for another login', async () => {
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet: { 'someone-else@example.org': true } });
await push(delivery('a', 'e1'));
expect(shade.size).toBe(1);
});
it('does not bring the message up later', async () => {
const off = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet });
await off.push(delivery('a', 'e1'));
await off.push(delivery('a', 'e1'));
expect(off.shade.size).toBe(0);
});
it('keeps showing with only the sound off', async () => {
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { prefs: { sound: false } });
await push(delivery('a', 'e1'));
expect(shade.get('bulwark-mail:a')!.options).toMatchObject({ silent: true, renotify: false });
});
it('shows for a push without a label, or with nothing stored', async () => {
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }), { quiet });
await push({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] });
expect(shade.size).toBe(1);
});
});
describe('which login a push is for', () => {
it('names the login in the preview request', async () => {
const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }));
await push(delivery('a', 'e1'));
expect(previews[0].searchParams.get('login')).toBe('me@example.org');
});
it('leaves it out when the push has no label', async () => {
const { push, previews } = loadWorker(() => ({ unreadTotal: 1, slot: 0, email: mail('e1') }));
await push({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] });
expect(previews[0].searchParams.has('login')).toBe(false);
});
});
describe('when building the notification goes wrong', () => {
it('still shows one', async () => {
const { push, shade } = loadWorker(() => ({ unreadTotal: 1, slot: 0, login: 'fp', email: mail('e1') }), {
globals: { Notification: { get maxActions() { throw new Error('no such thing'); } } },
});
await push(delivery('a', 'e1'));
expect(shade.size).toBe(1);
});
it('shows one for a push that is no JSON at all', async () => {
const { dispatch, shade } = loadWorker(() => null);
await dispatch('push', { data: { json: () => { throw new SyntaxError('not JSON'); } } });
expect(shade.size).toBe(1);
});
it('shows one for a push without any data', async () => {
const { dispatch, shade } = loadWorker(() => null);
await dispatch('push', {});
expect(shade.size).toBe(1);
});
});
describe('which window a click uses', () => {
const win = (id: string, url: string, extra: Record<string, unknown> = {}) => {
const client = {
id, url: `https://mail.example.com${url}`, frameType: 'top-level', visibilityState: 'hidden', focused: false,
postMessage: vi.fn(),
focus: vi.fn(async () => client),
navigate: vi.fn(async () => client),
...extra,
};
return client;
};
const clickOn = (w: ReturnType<typeof loadWorker>) => w.dispatch('notificationclick', {
notification: { tag: 'bulwark-mail:a', close() {}, data: { kind: 'email', emailId: 'e1', accountId: 'a', slot: 0 } },
});
it('leaves a pop-out and a frame alone and takes the app window', async () => {
const popout = win('p', '/popout/message/x');
const frame = win('f', '/mail/folder/inbox', { frameType: 'nested' });
const main = win('m', '/mail/folder/inbox');
const w = loadWorker(() => null, { clients: [popout, frame, main] });
await clickOn(w);
expect(popout.navigate).not.toHaveBeenCalled();
expect(frame.navigate).not.toHaveBeenCalled();
expect(main.navigate).toHaveBeenCalledWith('/mail/message/e1?slot=0&owner=a');
});
it('takes the window in front when there are several', async () => {
const back = win('b', '/mail/folder/inbox');
const front = win('f', '/mail/folder/inbox', { focused: true, visibilityState: 'visible' });
const w = loadWorker(() => null, { clients: [back, front] });
await clickOn(w);
expect(front.navigate).toHaveBeenCalled();
expect(back.navigate).not.toHaveBeenCalled();
});
it('opens a new window when only a pop-out is open', async () => {
const popout = win('p', '/popout/compose');
const openWindow = vi.fn(async () => null);
const w = loadWorker(() => null, { clients: [popout], openWindow });
await clickOn(w);
expect(popout.navigate).not.toHaveBeenCalled();
expect(openWindow).toHaveBeenCalledWith('/mail/message/e1?slot=0&owner=a');
});
});
describe('nothing new to say', () => {
it('removes only what it showed itself', async () => {
const { push, shade } = loadWorker(() => ({ unreadTotal: 0, email: null }));
await push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } });
expect(shade.size).toBe(0);
});
it('still shows something when the browser cannot list its notifications', async () => {
const shown: string[] = [];
const w = loadWorker(() => ({ unreadTotal: 0, email: null }), {
registration: {
showNotification: async (title: string) => { shown.push(title); },
getNotifications: async () => { throw new Error('nope'); },
},
});
await w.push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } });
expect(shown).toHaveLength(1);
});
it('uses its own tag, so it can never replace a real notification', async () => {
const tags: string[] = [];
const w = loadWorker(() => ({ unreadTotal: 0, email: null }), {
registration: {
showNotification: async (_title: string, options: { tag: string }) => { tags.push(options.tag); },
getNotifications: async () => [],
},
});
await w.push({ kind: 'jmap-state-change', accountId: 'a', changed: { a: { EmailDelivery: 's' } } });
expect(tags).toHaveLength(1);
expect(tags[0]).not.toBe('bulwark-mail:a');
});
});
describe('a push naming several accounts, one server slow', () => {
it('does not make the others wait out its deadline', async () => {
const started: string[] = [];
let release = () => {};
const slow = new Promise<void>((resolve) => { release = resolve; });
const fetchSlow = vi.fn(async (input: string) => {
const url = new URL(input, 'https://mail.example.com');
const account = url.searchParams.get('accountId') ?? '';
started.push(account);
if (account === 'slow') await slow;
return { ok: true, json: async () => ({ unreadTotal: 1, slot: 0, email: mail(`m-${account}`) }) };
});
const w = loadWorker(() => null, { globals: { fetch: fetchSlow } });
const done = w.push({ kind: 'jmap-state-change', accountId: 'slow', changed: { slow: { EmailDelivery: 's' }, fast: { EmailDelivery: 's' } } });
await vi.waitFor(() => expect(started).toEqual(['slow', 'fast']));
await vi.waitFor(() => expect(w.shade.has('bulwark-mail:fast')).toBe(true));
release();
await done;
expect(w.shade.size).toBe(2);
});
});
// -- end of cases --
+6 -6
View File
@@ -15,16 +15,16 @@ function parseTarget(target: string) {
describe('push click keeps the owning mailbox (#1491)', () => {
for (const owner of ['personal', 'team']) {
for (const unreadTotal of [1, 3]) {
for (const onScreen of [0, 2]) {
for (const warm of [false, true]) {
it(`${owner}, ${unreadTotal} unread, ${warm ? 'existing' : 'new'} window`, async () => {
it(`${owner}, ${onScreen} announced already, ${warm ? 'existing' : 'new'} window`, async () => {
const target = await pushClick(owner, async (url) => {
expect(new URL(url, 'https://mail.example.test').searchParams.get('accountId')).toBe(owner);
return { slot: 0, unreadTotal, email: { id: 'same-id', threadId: 't', subject: 'Mail' } };
}, { warm });
return { slot: 0, unreadTotal: 3, email: { id: 'same-id', threadId: 't', subject: 'Mail' } };
}, { warm, onScreen: Array.from({ length: onScreen }, (_, i) => `earlier-${i}`) });
const link = parseTarget(target);
expect(link?.slot).toBe(0);
if (unreadTotal === 1) {
if (onScreen === 0) {
expect(link).toMatchObject({ kind: 'message', id: 'same-id', ownerAccountId: owner });
} else {
expect(link).toMatchObject({ kind: 'folder', ref: 'inbox', ownerAccountId: owner });
@@ -42,7 +42,7 @@ describe('push click keeps the owning mailbox (#1491)', () => {
});
it('keeps the login slot and owner with a mount prefix and legacy payload', async () => {
const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 3, email: { id: 'same-id' } }), { basePath: '/webmail', legacy: true });
const target = await pushClick('team', async () => ({ slot: 2, unreadTotal: 3, email: { id: 'same-id' } }), { basePath: '/webmail', legacy: true, onScreen: ['earlier'] });
expect(target).toMatch(/^\/webmail\/mail\/folder\/inbox\?/);
expect(parseTarget(target)).toMatchObject({ kind: 'folder', ownerAccountId: 'team', slot: 2 });
});
+2 -2
View File
@@ -45,10 +45,10 @@ describe('service worker with a redacted preview', () => {
expect(calls[0].options.data).toMatchObject({ kind: 'email', emailId: 'e1' });
});
it('counts several unread messages without naming any', async () => {
it('opens the one new message however much else is unread', async () => {
const { push, calls } = loadWorker({ unreadTotal: 3, redacted: true, email: { id: 'e1', threadId: 't1' } });
await push(delivery);
expect(calls[0].title).toBe('New mail');
expect(calls[0].options.body).toBe('3 unread messages');
expect(calls[0].options.data).toMatchObject({ kind: 'email', emailId: 'e1' });
});
});
@@ -7,9 +7,11 @@ import { describe, expect, it, vi } from 'vitest';
// in a VM with a minimal ServiceWorkerGlobalScope and fire the event.
const SW_SOURCE = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) {
function loadWorker(tabs: { postMessage: (msg: unknown) => void }[], registrations?: unknown, config: { noThirdPartyTraffic?: boolean } | null = {}) {
const listeners = new Map<string, (event: unknown) => void>();
const subscribe = vi.fn(async () => ({}));
const newSubscription = { toJSON: () => ({ endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } }) };
const subscribe = vi.fn(async (_options: { userVisibleOnly?: boolean; applicationServerKey?: Uint8Array }) => newSubscription);
const relayCalls: Array<{ url: string; body: unknown }> = [];
const self = {
location: { href: 'https://mail.example.com/sw.js', origin: 'https://mail.example.com' },
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
@@ -18,7 +20,18 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) {
registration: { pushManager: { subscribe } },
};
const context = vm.createContext({
self, caches: {}, fetch: vi.fn(), URL, URLSearchParams, Response, Date, console,
self,
atob,
caches: { open: async () => ({ match: async () => (registrations ? { json: async () => registrations } : undefined) }) },
fetch: vi.fn(async (url: string, init?: { body: string }) => {
if (url.endsWith('/api/config')) {
if (!config) throw new TypeError('offline');
return { ok: true, json: async () => config };
}
relayCalls.push({ url, body: JSON.parse(init!.body) });
return { ok: true };
}),
URL, URLSearchParams, Response, Date, console, Uint8Array,
});
vm.runInContext(SW_SOURCE, context);
@@ -27,13 +40,57 @@ function loadWorker(tabs: { postMessage: (msg: unknown) => void }[]) {
listeners.get(type)?.({ ...event, waitUntil: (p: Promise<unknown>) => waits.push(p) });
await Promise.all(waits);
}
return { fire, subscribe };
return { fire, subscribe, relayCalls, newSubscription };
}
// The push service dropped or replaced the subscription while no page was
// involved (#1321). The worker puts a subscription back and tells the open
// tabs, which hold the login needed to register the new endpoint.
describe('service worker pushsubscriptionchange', () => {
const REGISTRATIONS = {
applicationServerKey: 'QUJD',
entries: {
a: { relay: 'https://relay.example', subscriptionId: 'device-a', accountLabel: 'a@example.org' },
b: { relay: 'https://relay.example', subscriptionId: 'device-b', accountLabel: 'b@example.org' },
},
};
it('tells the relay the new endpoint itself, for every account, with no tab open', async () => {
const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS);
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription });
expect(relayCalls).toEqual([
{ url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-a', subscription: newSubscription.toJSON(), accountLabel: 'a@example.org' } },
{ url: 'https://relay.example/api/push/register/web', body: { subscriptionId: 'device-b', subscription: newSubscription.toJSON(), accountLabel: 'b@example.org' } },
]);
});
it('subscribes with the kept key when the browser gives no old subscription', async () => {
const { fire, subscribe, relayCalls } = loadWorker([], REGISTRATIONS);
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription: null });
expect(subscribe).toHaveBeenCalledTimes(1);
expect(subscribe.mock.calls[0][0]).toMatchObject({ userVisibleOnly: true });
expect(Array.from(subscribe.mock.calls[0][0].applicationServerKey!)).toEqual([65, 66, 67]);
expect(relayCalls).toHaveLength(2);
});
it('calls no relay while third-party traffic is off: the page moves it', async () => {
const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS, { noThirdPartyTraffic: true });
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription });
expect(relayCalls).toEqual([]);
});
it('calls no relay when it cannot find out', async () => {
const { fire, relayCalls, newSubscription } = loadWorker([], REGISTRATIONS, null);
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription });
expect(relayCalls).toEqual([]);
});
it('calls no relay for a browser where push was never on', async () => {
const { fire, relayCalls, newSubscription } = loadWorker([]);
await fire('pushsubscriptionchange', { oldSubscription: null, newSubscription });
expect(relayCalls).toEqual([]);
});
it('re-subscribes with the old key and tells the open tabs to re-register', async () => {
const tab = { postMessage: vi.fn() };
const { fire, subscribe } = loadWorker([tab]);
+663 -1
View File
@@ -6,7 +6,9 @@ import { RateLimitError, RequestTimeoutError } from '@/lib/jmap/client';
import {
buildEmailPushConfig,
disableWebPush,
dropAllWebPushRegistrations,
enableWebPush,
endWebPushForSignOut,
isWebPushEnabled,
listPushDevices,
mailAccountsWithoutPush,
@@ -16,8 +18,10 @@ import {
revokePushDevice,
waitForWebPushResync,
} from '@/lib/web-push';
import { webPushErrorKey } from '@/lib/web-push-errors';
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
import { keepRelayRegistration } from '@/lib/pwa-notify/worker-state';
const RELAY = 'https://relay.example';
const RELAY_BASE_KEY = `bulwark.push.relayBase.v1.${'acct-1'}`;
@@ -275,7 +279,7 @@ function installFetch(activeByDevice: Record<string, boolean | 'unknown'>) {
if (url.includes('/api/push/active/')) {
const device = url.split('/api/push/active/')[1];
const state = activeByDevice[device];
if (state === undefined || state === 'unknown') return json({ error: 'Unknown' }, 404);
if (state === undefined || state === 'unknown') return json({ error: 'Unknown subscription' }, 404);
return json({ active: state });
}
return json({});
@@ -472,6 +476,532 @@ describe('enableWebPush', () => {
// Registrations made before the client learned about the delivery filter are
// repaired in the background on app start - nobody should have to find the
// settings toggle to stop the spam pushes.
describe('enableWebPush when the verification does not come', () => {
// The relay's poll stays empty until `arrivesAfterTouch` calls come in, like a
// Stalwart that skipped the verification until the subscription is touched.
function installSilentRelay(codeOnceTouched: () => boolean) {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response;
if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' });
if (url.includes('/api/push/verify/')) return json({ verificationCode: codeOnceTouched() ? 'late-code' : null });
return json({});
});
vi.stubGlobal('fetch', fetchMock);
}
afterEach(() => { vi.useRealTimers(); });
it('keeps polling through a network blip', async () => {
vi.useFakeTimers();
const client = makeClient([]);
let polls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response;
if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' });
if (url.includes('/api/push/verify/')) {
polls += 1;
if (polls === 1) throw new TypeError('Failed to fetch');
return json({ verificationCode: 'code' });
}
return json({});
});
vi.stubGlobal('fetch', fetchMock);
const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', (error: Error) => error.message);
await vi.advanceTimersByTimeAsync(5_000);
expect(await settled).toBe('done');
expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'code');
});
it('removes its unverified subscription and reads as off when the code never comes', async () => {
vi.useFakeTimers();
const client = makeClient([]);
installSilentRelay(() => false);
const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', (error: Error) => error.message);
await vi.advanceTimersByTimeAsync(120_000);
expect(await settled).toMatch(/verif/i);
expect(client.destroyed).toEqual(['push-new']);
expect(localStorage.getItem(SUB_KEY)).toBeNull();
expect(client.verifyPushSubscription).not.toHaveBeenCalled();
});
it('removes its subscription when the server refuses the code', async () => {
const client = makeClient([]);
installFetch({});
(client.verifyPushSubscription as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Verification code does not match.'));
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toThrow('does not match');
expect(client.destroyed).toEqual(['push-new']);
expect(localStorage.getItem(SUB_KEY)).toBeNull();
});
it('keeps the old subscription working when its replacement fails verification', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)]);
installFetch({});
(client.verifyPushSubscription as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('nope'));
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false, forceRecreate: true })).rejects.toThrow('nope');
expect(client.destroyed).toEqual(['push-new']);
expect(localStorage.getItem(SUB_KEY)).toBe('push-old');
});
it('touches the subscription after the server\'s minute, which makes it send the code', async () => {
vi.useFakeTimers();
const client = makeClient([]);
installSilentRelay(() => (client.updatePushSubscription as ReturnType<typeof vi.fn>).mock.calls.length > 0);
const run = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
const settled = run.then(() => 'done', (error: Error) => error.message);
await vi.advanceTimersByTimeAsync(58_000);
expect(client.updatePushSubscription).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(10_000);
expect(client.updatePushSubscription).toHaveBeenCalledTimes(1);
expect((client.updatePushSubscription as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe('push-new');
await vi.advanceTimersByTimeAsync(5_000);
expect(await settled).toBe('done');
expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'late-code');
});
});
describe('two tabs at once', () => {
// What the browser's Web Locks do: one holder at a time, in order.
function installLocks() {
let tail: Promise<unknown> = Promise.resolve();
const request = vi.fn(async (_name: string, run: () => Promise<unknown>) => {
const next = tail.then(run, run);
tail = next.catch(() => undefined);
return next;
});
Object.defineProperty(navigator, 'locks', { configurable: true, value: { request } });
return request;
}
afterEach(() => {
delete (navigator as unknown as { locks?: unknown }).locks;
});
// A server that knows what was created and destroyed, as the race needs.
function statefulClient() {
const subs: JmapPushSubscription[] = [];
const client = makeClient(subs);
let next = 0;
(client.createPushSubscription as ReturnType<typeof vi.fn>).mockImplementation(async (params: { deviceClientId: string }) => {
const id = `push-${++next}`;
subs.push(sub(id, params.deviceClientId));
return id;
});
(client.verifyPushSubscription as ReturnType<typeof vi.fn>).mockImplementation(async (id: string) => {
if (client.destroyed.includes(id)) throw new Error('notFound');
});
return client;
}
it('makes one subscription, and neither tab fails', async () => {
installLocks();
const client = statefulClient();
installFetch({});
const results = await Promise.allSettled([
enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }),
enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }),
]);
expect(results.map((r) => r.status)).toEqual(['fulfilled', 'fulfilled']);
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
expect(client.destroyed).toEqual([]);
expect(await client.listPushSubscriptions()).toHaveLength(1);
});
it('waits for a running enable before turning off', async () => {
const request = installLocks();
const client = statefulClient();
installFetch({});
const enabling = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
await Promise.all([enabling, disableWebPush({ client, relayBaseUrl: RELAY })]);
expect(request).toHaveBeenCalledTimes(2);
expect(await client.listPushSubscriptions()).toEqual([]);
expect(localStorage.getItem(SUB_KEY)).toBeNull();
});
it('still works in a browser without Web Locks', async () => {
const client = statefulClient();
installFetch({});
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
});
});
describe('what enabling and disabling say when they fail', () => {
it('tells a blocked browser from a question not answered', async () => {
const client = makeClient([]);
installFetch({});
(globalThis as unknown as { Notification: unknown }).Notification = { permission: 'denied' };
(window as unknown as { Notification: unknown }).Notification = { permission: 'denied' };
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_blocked');
const unanswered = { permission: 'default', requestPermission: async () => 'default' };
(globalThis as unknown as { Notification: unknown }).Notification = unanswered;
(window as unknown as { Notification: unknown }).Notification = unanswered;
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_not_granted');
expect(client.createPushSubscription).not.toHaveBeenCalled();
});
it('names an unreachable relay, and a relay that refuses', async () => {
const client = makeClient([]);
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); }));
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_relay');
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response;
return String(input).endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({}, 500);
}));
await expect(enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_relay');
expect(client.createPushSubscription).not.toHaveBeenCalled();
});
it('names a verification that never came', async () => {
vi.useFakeTimers();
try {
const client = makeClient([]);
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response;
if (url.endsWith('/vapid-public-key')) return json({ publicKey: 'QUJD' });
return json({ verificationCode: null });
}));
const settled = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).catch((e: unknown) => webPushErrorKey(e));
await vi.advanceTimersByTimeAsync(120_000);
expect(await settled).toBe('error_verification_timeout');
} finally {
vi.useRealTimers();
}
});
it('keeps naming the server refusals, and nothing for an unknown error', () => {
expect(webPushErrorKey(refusedMap())).toBe('error_forbidden');
expect(webPushErrorKey(jmapSetError('x', OVER_QUOTA))).toBe('error_over_quota');
expect(webPushErrorKey(new Error('boom'))).toBeNull();
});
it('says so when turning off could not reach the server, and still ends up off here', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
installFetch({});
(client.destroyPushSubscription as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('offline'));
await expect(disableWebPush({ client, relayBaseUrl: RELAY })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_teardown');
expect(localStorage.getItem(SUB_KEY)).toBeNull();
});
it('says so when turning off could not reach the relay', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); }));
await expect(disableWebPush({ client, relayBaseUrl: RELAY })).rejects.toSatisfy((e) => webPushErrorKey(e) === 'error_teardown');
expect(client.destroyed).toEqual(['push-mine']);
expect(localStorage.getItem(SUB_KEY)).toBeNull();
});
it('does not count a record the relay no longer has as a failure', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) })));
await expect(disableWebPush({ client, relayBaseUrl: RELAY })).resolves.toBeUndefined();
});
it('keeps the browser subscription for the other account, and says nothing when all went well', async () => {
const { browserSub } = installPushBrowser();
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
localStorage.setItem('bulwark.push.subscriptionId.v1.other-account', 'push-other');
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
installFetch({});
await expect(disableWebPush({ client, relayBaseUrl: RELAY })).resolves.toBeUndefined();
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
});
});
describe('an endpoint the push service gave up on', () => {
const ENDPOINT_KEY = `bulwark.push.endpoint.v1.${ACCOUNT_ID}`;
function registered() {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
localStorage.setItem(RELAY_BASE_KEY, RELAY);
localStorage.setItem(ENDPOINT_KEY, 'https://fcm.example/endpoint');
return makeClient([sub('push-mine', THIS_DEVICE)]);
}
it('gets a new subscription when the relay dropped the record for the very endpoint this browser still holds', async () => {
const { browserSub, registration } = installPushBrowser();
const client = registered();
installFetch({}); // The relay answers 404 for this device.
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1);
expect(registration.pushManager.subscribe).toHaveBeenCalledTimes(1);
});
it('does not take a relay that lacks the route for one that lost the record', async () => {
const { browserSub } = installPushBrowser();
const client = registered();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response;
if (url.includes('/api/push/active/')) return json({ error: 'Not found' }, 404);
return url.endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({});
}));
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
});
it('moves the other logins to the new endpoint, connected or not', async () => {
const { registration } = installPushBrowser();
const moved = { endpoint: 'https://fcm.example/moved', options: { applicationServerKey: new Uint8Array([65, 66, 67]).buffer }, getKey: () => new Uint8Array([1, 2, 3]).buffer, unsubscribe: vi.fn(async () => true) };
(registration.pushManager.subscribe as ReturnType<typeof vi.fn>).mockResolvedValue(moved);
const stored = new Map<string, string>();
vi.stubGlobal('caches', {
open: async () => ({
match: async (key: string) => (stored.has(key) ? new Response(stored.get(key)) : undefined),
put: async (key: string, res: Response) => { stored.set(key, await res.text()); },
}),
});
await keepRelayRegistration('other-login', { relay: 'https://relay-of-other.example', subscriptionId: 'device-other', accountLabel: 'o@example.org' }, 'QUJD');
const client = registered();
const bodies: Array<{ url: string; body: { subscriptionId: string; subscription: { endpoint: string }; accountLabel?: string } }> = [];
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const json = (body: unknown, status = 200) => ({ ok: status < 300, status, json: async () => body }) as Response;
if (url.endsWith('/vapid-public-key')) return json({ publicKey: 'QUJD' });
if (url.includes('/api/push/active/')) return json({ error: 'Unknown subscription' }, 404);
if (url.endsWith('/api/push/register/web')) bodies.push({ url, body: JSON.parse(String(init?.body)) });
return json({});
}));
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
const other = bodies.find((b) => b.body.subscriptionId === 'device-other');
expect(other?.url).toBe('https://relay-of-other.example/api/push/register/web');
expect(other?.body.subscription.endpoint).toBe('https://fcm.example/moved');
expect(other?.body.accountLabel).toBe('o@example.org');
expect(bodies.filter((b) => b.body.subscriptionId === THIS_DEVICE)).toHaveLength(1);
});
it('keeps the subscription while the relay knows the record', async () => {
const { browserSub } = installPushBrowser();
const client = registered();
installFetch({ [THIS_DEVICE]: true });
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
});
it('keeps it when the relay cannot be asked', async () => {
const { browserSub } = installPushBrowser();
const client = registered();
const calls: string[] = [];
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
calls.push(url);
if (url.includes('/api/push/active/')) throw new TypeError('blip');
const json = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response;
return url.endsWith('/vapid-public-key') ? json({ publicKey: 'QUJD' }) : json({});
}));
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(calls.some((url) => url.includes('/api/push/active/'))).toBe(true);
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
});
it('only registers when another account already moved the browser to a new endpoint', async () => {
const { browserSub } = installPushBrowser();
const client = registered();
localStorage.setItem(ENDPOINT_KEY, 'https://fcm.example/older');
installFetch({});
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
expect(localStorage.getItem(ENDPOINT_KEY)).toBe('https://fcm.example/endpoint');
});
it('does not mistake a move to another relay for a dead endpoint', async () => {
const { browserSub } = installPushBrowser();
const client = registered();
localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example');
installFetch({});
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
});
});
describe('a subscription id the server gave to another device', () => {
// Stalwart hands out the lowest free id: this browser's subscription expired,
// and the phone that registered next got the same id.
it('creates its own instead of taking over the other device\'s', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'b');
const client = makeClient([sub('b', OTHER_DEVICE)]);
installFetch({ [OTHER_DEVICE]: true });
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(client.updatePushSubscription).not.toHaveBeenCalled();
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
expect(client.destroyed).toEqual([]);
expect(localStorage.getItem(SUB_KEY)).toBe('push-new');
});
it('leaves it alone when turning off', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'b');
const client = makeClient([sub('b', OTHER_DEVICE)]);
installFetch({});
await disableWebPush({ client, relayBaseUrl: RELAY });
expect(client.destroyed).toEqual([]);
});
it('leaves it alone on Re-register', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'b');
const client = makeClient([sub('b', OTHER_DEVICE)]);
installFetch({ [OTHER_DEVICE]: true });
await enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false, forceRecreate: true });
expect(client.destroyed).toEqual([]);
});
});
describe('a setup that gets stuck', () => {
afterEach(() => {
vi.useRealTimers();
delete (navigator as unknown as { locks?: unknown }).locks;
});
it('fails in the end and lets the next one through', async () => {
vi.useFakeTimers();
let tail: Promise<unknown> = Promise.resolve();
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: { request: (_n: string, run: () => Promise<unknown>) => { const next = tail.then(run, run); tail = next.catch(() => undefined); return next; } },
});
const { registration } = installPushBrowser();
(registration.pushManager.getSubscription as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
(registration.pushManager.subscribe as ReturnType<typeof vi.fn>).mockReturnValueOnce(new Promise(() => {}));
const client = makeClient([]);
installFetch({});
const stuck = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', () => 'failed');
await vi.advanceTimersByTimeAsync(1_000);
const second = enableWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false }).then(() => 'done', () => 'failed');
await vi.advanceTimersByTimeAsync(200_000);
expect(await stuck).toBe('failed');
expect(await second).toBe('done');
});
it('turns off for the account it was asked about, even if the client disconnected while waiting', async () => {
let tail: Promise<unknown> = Promise.resolve();
let release = () => {};
tail = new Promise<void>((resolve) => { release = resolve; });
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: { request: (_n: string, run: () => Promise<unknown>) => { const next = tail.then(run, run); tail = next.catch(() => undefined); return next; } },
});
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
localStorage.setItem(RELAY_BASE_KEY, RELAY);
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
let accountId = ACCOUNT_ID;
(client as unknown as { getAccountId: () => string }).getAccountId = () => accountId;
const calls = installFetch({});
const off = disableWebPush({ client, relayBaseUrl: RELAY }).catch(() => undefined);
accountId = '';
release();
await off;
expect(localStorage.getItem(SUB_KEY)).toBeNull();
expect(calls).toContainEqual({ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' });
});
});
describe('a re-sync waiting its turn', () => {
afterEach(() => { delete (navigator as unknown as { locks?: unknown }).locks; });
it('does not bring back what another tab turned off meanwhile', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
localStorage.setItem(RELAY_BASE_KEY, RELAY);
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
installFetch({ [THIS_DEVICE]: true });
let turn: Promise<unknown> = Promise.resolve();
let release = () => {};
turn = new Promise<void>((resolve) => { release = resolve; });
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: { request: (_n: string, run: () => Promise<unknown>) => { const next = turn.then(run, run); turn = next.catch(() => undefined); return next; } },
});
const resync = resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
await vi.waitFor(() => expect((navigator as unknown as { locks: unknown }).locks).toBeTruthy());
await new Promise((resolve) => setTimeout(resolve, 0));
localStorage.removeItem(SUB_KEY); // The other tab, holding the lock.
release();
await resync;
expect(client.updatePushSubscription).not.toHaveBeenCalled();
expect(client.createPushSubscription).not.toHaveBeenCalled();
expect(localStorage.getItem(SUB_KEY)).toBeNull();
});
});
describe('a registration from before the relay was remembered', () => {
it('gets the relay noted when it is refreshed', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
installFetch({ [THIS_DEVICE]: true });
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(localStorage.getItem(RELAY_BASE_KEY)).toBe(RELAY);
expect(client.createPushSubscription).not.toHaveBeenCalled();
});
});
describe('resyncWebPush when the relay changed (#903)', () => {
afterEach(() => setNoThirdPartyTraffic(false));
@@ -504,6 +1034,48 @@ describe('resyncWebPush when the relay changed (#903)', () => {
expect(client.destroyed).toContain('push-old');
});
it('leaves no record on the relay it moved away from', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example');
const client = makeClient([sub('push-old', THIS_DEVICE)]);
const calls = installFetch({});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
expect(calls).toContainEqual({ url: `https://old-relay.example/api/push/register/${THIS_DEVICE}`, method: 'DELETE' });
expect(calls).not.toContainEqual({ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' });
expect(localStorage.getItem(RELAY_BASE_KEY)).toBe(RELAY);
expect(client.destroyed).toEqual(['push-old']);
});
it('keeps the old relay until the new subscription is verified', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example');
const client = makeClient([sub('push-old', THIS_DEVICE)]);
const calls = installFetch({});
(client.verifyPushSubscription as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('nope'));
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(calls.some((c) => c.method === 'DELETE')).toBe(false);
expect(localStorage.getItem(RELAY_BASE_KEY)).toBe('https://old-relay.example');
});
it('does not contact the built-in relay to clean up when third-party traffic is off', async () => {
setNoThirdPartyTraffic(true);
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
localStorage.setItem(RELAY_BASE_KEY, DEFAULT_RELAY_BASE_URL);
const client = makeClient([sub('push-old', THIS_DEVICE)]);
const calls = installFetch({});
await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false });
expect(calls.filter((c) => c.url.startsWith(DEFAULT_RELAY_BASE_URL))).toEqual([]);
});
it('re-registers inside the daily limit when the admin switched relays, and only once', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
@@ -1388,6 +1960,18 @@ describe('disableWebPush', () => {
expect(localStorage.getItem(REFUSED_KEY)).toBeNull();
});
it('drops the record on the relay it was registered with, not only the current one', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-tracked');
localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example');
const client = makeClient([sub('push-tracked', THIS_DEVICE)]);
const calls = installFetch({});
await disableWebPush({ client, relayBaseUrl: RELAY });
expect(calls).toContainEqual({ url: `https://old-relay.example/api/push/register/${THIS_DEVICE}`, method: 'DELETE' });
});
it('drops the relay registration for this device', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
const client = makeClient([]);
@@ -1402,6 +1986,84 @@ describe('disableWebPush', () => {
});
});
describe('endWebPushForSignOut', () => {
it('takes everything down for a login with push on, using the relay it registered with', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
localStorage.setItem(RELAY_BASE_KEY, RELAY);
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
const calls = installFetch({});
await endWebPushForSignOut(client);
expect(client.destroyed).toEqual(['push-mine']);
expect(calls).toEqual([{ url: `${RELAY}/api/push/register/${THIS_DEVICE}`, method: 'DELETE' }]);
expect(localStorage.getItem(SUB_KEY)).toBeNull();
});
it('does nothing and calls nobody for a login without push', async () => {
const client = makeClient([]);
const calls = installFetch({});
await endWebPushForSignOut(client);
expect(calls).toEqual([]);
expect(client.listPushSubscriptions).not.toHaveBeenCalled();
});
it('never throws: signing out goes on', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
localStorage.setItem(RELAY_BASE_KEY, RELAY);
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('offline'); }));
(client.destroyPushSubscription as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('offline'));
await expect(endWebPushForSignOut(client)).resolves.toBeUndefined();
expect(localStorage.getItem(SUB_KEY)).toBeNull();
});
it('forgets the key material with nobody left', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-mine');
localStorage.setItem(RELAY_BASE_KEY, RELAY);
const { browserSub } = installPushBrowser();
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
installFetch({});
await endWebPushForSignOut(client);
expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1);
});
});
describe('dropAllWebPushRegistrations', () => {
it('takes down every login this browser registered, connected or not, and calls no relay it did not use', async () => {
const { browserSub } = installPushBrowser();
localStorage.setItem('bulwark.push.subscriptionId.v1.a', 'x');
localStorage.setItem('bulwark.push.deviceClientId.v1.a', 'device-a');
localStorage.setItem('bulwark.push.relayBase.v1.a', RELAY);
localStorage.setItem('bulwark.push.subscriptionId.v1.b', 'y');
localStorage.setItem('bulwark.push.deviceClientId.v1.b', 'device-b');
const calls = installFetch({});
await dropAllWebPushRegistrations();
expect(calls).toEqual([{ url: `${RELAY}/api/push/register/device-a`, method: 'DELETE' }]);
expect(localStorage.getItem('bulwark.push.subscriptionId.v1.a')).toBeNull();
expect(localStorage.getItem('bulwark.push.subscriptionId.v1.b')).toBeNull();
expect(browserSub.unsubscribe).toHaveBeenCalledTimes(1);
});
it('does nothing with nothing registered', async () => {
const { browserSub } = installPushBrowser();
const calls = installFetch({});
await dropAllWebPushRegistrations();
expect(calls).toEqual([]);
expect(browserSub.unsubscribe).not.toHaveBeenCalled();
});
});
describe('listPushDevices', () => {
it('flags this device and reports the relay status of each registration', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
+2 -1
View File
@@ -6,6 +6,7 @@ import { assertWritable, isConfigReadOnly } from './paths';
import { adminConfigDocs, adminStateDocs, isStoreUnavailable } from './admin-store';
import type { AdminConfigData, AdminStateData } from './types';
import { readFileEnv } from '../read-file-env';
import { desktopServerSecret } from '@/lib/desktop/server-secrets';
const SCRYPT_KEYLEN = 64;
const SCRYPT_COST = 16384; // 2^14
@@ -269,7 +270,7 @@ export async function initAdminPassword(): Promise<boolean> {
return true;
}
const envPassword = process.env.ADMIN_PASSWORD;
const envPassword = desktopServerSecret('adminPassword') || process.env.ADMIN_PASSWORD;
if (!envPassword) {
if (!cache.initialized) logger.info('Admin dashboard disabled (no ADMIN_PASSWORD set)');
cache.initialized = true;
+3
View File
@@ -300,6 +300,9 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
// password. For JMAP servers that hand out API tokens, such as Fastmail,
// whose API accepts no passwords at all.
loginShowTokenLogin: { envVar: 'LOGIN_SHOW_TOKEN_LOGIN', type: 'boolean', defaultValue: false },
// The desktop app's "Sign in with your browser". Turn off on a terminal server: another
// user there can bind a loopback port and send a link that asks for someone's sign-in.
desktopBrowserSignIn: { envVar: 'DESKTOP_BROWSER_SIGN_IN', type: 'boolean', defaultValue: true },
// Show the "Remember me" box on the login form. Off (the default) remembers
// every sign-in, as long as a SESSION_SECRET makes that possible.
loginShowRememberMe: { envVar: 'LOGIN_SHOW_REMEMBER_ME', type: 'boolean', defaultValue: false },
+4 -1
View File
@@ -123,7 +123,9 @@ export type PayloadPurpose =
/** A parked mobile hand-off sign-in in the shared store (lib/auth/handoff-store.ts). */
| 'handoff-store'
/** Which server a desktop account's refresh token belongs to (lib/oauth/custom-upstream.ts). */
| 'custom-upstream';
| 'custom-upstream'
/** Proof of an earlier session, for wiping a dropped account's plugin secrets (lib/plugin-secrets/wipe-proof.ts). */
| 'plugin-wipe-proof';
/**
* Whether a blob minted before purposes were bound may be re-admitted for
@@ -148,6 +150,7 @@ const LEGACY_PAYLOAD_MARKERS: Record<PayloadPurpose, (payload: Record<string, un
'pairing-store': () => false,
'handoff-store': () => false,
'custom-upstream': () => false,
'plugin-wipe-proof': () => false,
};
export function encryptPayload(payload: Record<string, unknown>, purpose: PayloadPurpose): string {
+33
View File
@@ -0,0 +1,33 @@
import { logger } from '@/lib/logger';
import type { ParkedHandoff } from '@/lib/auth/handoff-store';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { buildOAuthParams, DEFAULT_CLIENT_ID, getDiscoveryValidator, getRequiredConfig } from '@/lib/oauth/token-exchange';
const REVOCATION_TIMEOUT_MS = 3000;
/**
* A refresh token that was minted and then not wanted: declined, or refused by the device's
* policy. Revoked where the provider has an endpoint for it (Stalwart has none, and there the
* token just stops being held). Never throws: nobody is waiting to hear about a failure.
*/
export async function revokeParkedTokens(parked: ParkedHandoff): Promise<void> {
if (parked.flow !== 'desktop-tokens' || !parked.refresh_token) return;
try {
const options = { fallbackClientId: DEFAULT_CLIENT_ID };
const { discoveryUrl } = getRequiredConfig(parked.server_id, options);
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator(discoveryUrl) });
if (!metadata?.revocation_endpoint) return;
const params = buildOAuthParams({ token: parked.refresh_token, token_type_hint: 'refresh_token' }, parked.server_id, options);
// The body holds the refresh token: no redirect is followed with it.
const response = await fetch(metadata.revocation_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
signal: AbortSignal.timeout(REVOCATION_TIMEOUT_MS),
redirect: 'error',
});
if (!response.ok) logger.warn('Declined token revocation returned error', { status: response.status });
} catch (error) {
logger.warn('Declined token revocation failed', { error: error instanceof Error ? error.message : 'Unknown error' });
}
}
+63
View File
@@ -0,0 +1,63 @@
import { configManager } from '@/lib/admin/config-manager';
import { findServerById, findServerByUrl, parseJmapServers, resolveTrustedJmapUrl, type JmapServerEntry } from '@/lib/admin/jmap-servers';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
/**
* What the desktop app's sign-in routes share (app/api/auth/desktop-handoff). The same
* pinning as the other sign-in routes, and the instance admin's switch: on a terminal
* server another local user can bind a loopback port, so the admin can turn the hand-off
* off there. A device's own `BrowserSignIn` doesn't reach this: it's about the app, and
* the other user's link opens in a browser.
*/
export interface DesktopHandoffRefusal {
error: string;
status: number;
}
export function desktopHandoffEnabled(): boolean {
return !IS_DESKTOP_BUILD && configManager.get<boolean>('desktopBrowserSignIn', true);
}
export type DesktopHandoffServer =
| { refusal: DesktopHandoffRefusal }
| {
entry: JmapServerEntry | undefined;
/** What's checked and parked, whatever the body said. */
resolved: string;
upstream: string;
trusted: boolean;
/** The user's own server, by `allowCustomJmapEndpoint`. A device's policy may refuse it. */
custom: boolean;
};
export async function resolveDesktopHandoffServer(serverUrl: string, serverId: unknown): Promise<DesktopHandoffServer> {
await configManager.ensureLoaded();
if (!desktopHandoffEnabled()) return { refusal: { error: 'disabled', status: 403 } };
if (configManager.get<boolean>('oauthEnabled', false) && configManager.get<boolean>('oauthOnly', false)) {
return { refusal: { error: 'oauth_only', status: 403 } };
}
const configured =
configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
const list = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const requested = typeof serverId === 'string' && serverId ? serverId : null;
const entry = findServerById(list, requested) || findServerByUrl(list, serverUrl) || undefined;
let resolved: string;
let trusted = true;
let custom = false;
if (entry) {
resolved = entry.url;
} else {
const pinned = resolveTrustedJmapUrl(serverUrl, configured, list);
if (pinned) {
resolved = pinned;
} else if (configManager.get<boolean>('allowCustomJmapEndpoint', false) && (await isPublicHttpUrl(serverUrl))) {
resolved = serverUrl;
trusted = false;
custom = true;
} else {
return { refusal: { error: 'invalid_server_url', status: 400 } };
}
}
return { entry, resolved, upstream: resolved.replace(/\/+$/, ''), trusted, custom };
}
+96 -12
View File
@@ -59,9 +59,41 @@ export function isHandoffCode(value: unknown): value is string {
return typeof value === 'string' && CODE_RE.test(value);
}
/**
* What the desktop app's sign-in parks (app/api/auth/desktop-handoff). The app's redeem
* writes its own cookies from this, so these fields never go to anyone else: the mobile
* redeem route refuses them.
*/
export type DesktopHandoffBundle =
| {
flow: 'desktop-password';
server_url: string;
server_id: string | null;
/** The user's own server, which a device's policy may refuse. */
custom: boolean;
username: string;
password: string;
}
| {
flow: 'desktop-tokens';
server_url: string;
server_id: string | null;
custom: boolean;
/** From a typed password, not a provider's page: a password account, as the user knows it. */
password_login?: boolean;
username?: string;
access_token: string;
refresh_token?: string;
id_token?: string;
expires_in: number;
signed_in_at: number;
};
export type ParkedHandoff = RedeemBundle | DesktopHandoffBundle;
interface HandoffRecord {
/** Null once redeemed (or tried) or expired. */
bundle: RedeemBundle | null;
bundle: ParkedHandoff | null;
state: string;
challenge: string;
/** Whether the user confirmed the hand-off; only confirmed codes redeem. */
@@ -72,7 +104,7 @@ interface HandoffRecord {
}
export type HandoffRedeemResult =
| { ok: true; bundle: RedeemBundle; state: string }
| { ok: true; bundle: ParkedHandoff; state: string }
| { ok: false; reason: 'invalid' | 'expired' | 'verifier_mismatch' };
const STORE_KEY = '__bulwarkMobileHandoffStore';
@@ -83,8 +115,9 @@ function store(): Map<string, HandoffRecord> {
return g[STORE_KEY];
}
// Wipe sign-ins past their expiry and forget tombstones past theirs. Called
// on every operation, so memory stays bounded without a timer.
// Wipe sign-ins past their expiry and forget tombstones past theirs. Called on
// every operation, and by a timer at each record's expiry: a sign-in nobody asks
// about again must not stay in memory until someone happens to call in.
function sweep(now: number): void {
const codes = store();
for (const [code, record] of codes) {
@@ -93,12 +126,17 @@ function sweep(now: number): void {
}
}
function wipeWhenDue(expiresAt: number, now: number): void {
const timer = setTimeout(() => sweep(Date.now()), Math.max(expiresAt - now, 0) + 1);
timer.unref?.();
}
function s256(verifier: string): string {
return createHash('sha256').update(verifier, 'ascii').digest('base64url');
}
function newRecord(
bundle: RedeemBundle,
bundle: ParkedHandoff,
options: { challenge: string; state: string; awaitConfirm?: boolean },
now: number,
): HandoffRecord {
@@ -121,7 +159,7 @@ function confirmed(record: HandoffRecord, now: number): HandoffRecord {
}
/** What a redeem attempt of `record` (spent by the attempt) gets. */
function redeemOutcome(record: HandoffRecord, bundle: RedeemBundle | null, verifier: string, now: number): HandoffRedeemResult {
function redeemOutcome(record: HandoffRecord, bundle: ParkedHandoff | null, verifier: string, now: number): HandoffRedeemResult {
if (!bundle) return { ok: false, reason: record.expiresAt <= now ? 'expired' : 'invalid' };
if (!record.confirmed) return { ok: false, reason: 'invalid' };
const expected = Buffer.from(record.challenge, 'ascii');
@@ -129,7 +167,7 @@ function redeemOutcome(record: HandoffRecord, bundle: RedeemBundle | null, verif
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
return { ok: false, reason: 'verifier_mismatch' };
}
if (bundle.flow === 'oauth' && typeof bundle.expires_in === 'number') {
if ((bundle.flow === 'oauth' || bundle.flow === 'desktop-tokens') && typeof bundle.expires_in === 'number') {
const elapsed = Math.floor((now - record.issuedAt) / 1000);
return { ok: true, bundle: { ...bundle, expires_in: Math.max(0, bundle.expires_in - elapsed) }, state: record.state };
}
@@ -139,6 +177,8 @@ function redeemOutcome(record: HandoffRecord, bundle: RedeemBundle | null, verif
// ─── shared store ──────────────────────────────────────────────────────────
const sharedKey = (code: string) => `handoff:${createHash('sha256').update(code).digest('hex')}`;
/** Holds no sign-in, only when it runs out. So a code past that, with its record gone, is told "expired". */
const issuedKey = (code: string) => `handoff-issued:${createHash('sha256').update(code).digest('hex')}`;
function seal(record: HandoffRecord): string {
return encryptPayload({ r: record }, 'handoff-store');
@@ -153,6 +193,8 @@ function open(sealed: string | null, now: number): HandoffRecord | null {
}
const ttl = (record: HandoffRecord, now: number) => Math.max(record.forgetAt - now, 0) + CLEANUP_MS;
/** A record with a sign-in in it stays no longer than the sign-in is good for. */
const secretTtl = (record: HandoffRecord, now: number) => Math.max(record.expiresAt - now, 1);
// ─── API ───────────────────────────────────────────────────────────────────
@@ -161,7 +203,7 @@ const ttl = (record: HandoffRecord, now: number) => Math.max(record.forgetAt - n
* `awaitConfirm` the code only becomes redeemable through confirmHandoff.
*/
export async function stashHandoff(
bundle: RedeemBundle,
bundle: ParkedHandoff,
options: { challenge: string; state: string; awaitConfirm?: boolean },
now = Date.now(),
): Promise<string> {
@@ -170,7 +212,8 @@ export async function stashHandoff(
if (isSharedMode()) {
// Each code is spent by a single redeem and dies on its own; the create
// budget bounds how many one client parks.
await getStore().kv.set(sharedKey(code), seal(record), { ttlMs: ttl(record, now) });
await getStore().kv.set(sharedKey(code), seal(record), { ttlMs: secretTtl(record, now) });
await getStore().kv.set(issuedKey(code), String(record.expiresAt), { ttlMs: ttl(record, now) });
return code;
}
sweep(now);
@@ -182,6 +225,7 @@ export async function stashHandoff(
codes.delete(oldest);
}
codes.set(code, record);
wipeWhenDue(record.expiresAt, now);
return code;
}
@@ -199,16 +243,44 @@ export async function confirmHandoff(code: string, now = Date.now()): Promise<bo
if (!record || !record.bundle || record.expiresAt <= now || record.confirmed) return undefined;
result.ok = true;
return seal(confirmed(record, now));
}, { ttlMs: HANDOFF_TTL_MS + TOMBSTONE_MS + CLEANUP_MS });
}, { ttlMs: HANDOFF_TTL_MS });
if (result.ok) await getStore().kv.set(issuedKey(code), String(now + HANDOFF_TTL_MS), { ttlMs: HANDOFF_TTL_MS + TOMBSTONE_MS + CLEANUP_MS });
return result.ok;
}
sweep(now);
const record = store().get(code);
if (!record || !record.bundle || record.confirmed) return false;
store().set(code, confirmed(record, now));
const live = confirmed(record, now);
store().set(code, live);
wipeWhenDue(live.expiresAt, now);
return true;
}
/**
* The user said no to the desktop app, or left. The sign-in goes now, and what it held comes
* back once, so a refresh token can be revoked. Only the desktop's: the phone's flow has its own.
*/
export async function discardDesktopHandoff(code: string, now = Date.now()): Promise<ParkedHandoff | null> {
const isDesktop = (bundle: ParkedHandoff | null) => bundle?.flow === 'desktop-password' || bundle?.flow === 'desktop-tokens';
if (isSharedMode()) {
let taken: ParkedHandoff | null = null;
await getStore().kv.update(sharedKey(code), (current) => {
taken = null;
const record = open(current, now);
if (!record || !isDesktop(record.bundle)) return undefined;
taken = record.bundle;
return seal({ ...record, bundle: null });
}, { ttlMs: TOMBSTONE_MS + CLEANUP_MS });
return taken as ParkedHandoff | null;
}
sweep(now);
const record = store().get(code);
if (!record || !isDesktop(record.bundle)) return null;
const taken = record.bundle;
record.bundle = null;
return taken;
}
/**
* Hand out the sign-in behind `code` when `verifier` matches its challenge.
* The code is spent by the attempt, whatever its outcome. `expires_in` of an
@@ -219,7 +291,10 @@ export async function redeemHandoff(code: string, verifier: string, now = Date.n
const kv = getStore().kv;
// Taken in one step: of two attempts, only one sees the sign-in.
const record = open(await kv.take(sharedKey(code)), now);
if (!record) return { ok: false, reason: 'invalid' };
if (!record) {
const until = await kv.get(issuedKey(code));
return { ok: false, reason: until !== null && Number(until) <= now ? 'expired' : 'invalid' };
}
const bundle = record.expiresAt > now ? record.bundle : null;
// A tombstone goes back, so a late attempt is told "expired". The code is
// spent already: if the tombstone can't be written, the answer stands.
@@ -249,12 +324,21 @@ export function resetHandoffStoreForTests(): void {
// filling the store or hammering the redeem route.
const createBudget = createFailureBudget({ name: 'handoff-create', clientMax: 10, globalMax: Infinity });
const redeemBudget = createFailureBudget({ name: 'handoff-redeem', clientMax: 10, globalMax: Infinity });
const desktopParkBudget = createFailureBudget({ name: 'desktop-handoff-park', clientMax: 10, globalMax: Infinity });
/** Count one parked sign-in for `client`; false when it is over its limit. */
export async function reserveHandoffCreate(client: string, now = Date.now()): Promise<boolean> {
return (await createBudget.reserve(client, now)) !== null;
}
/**
* One park for the desktop app, reserved before anything goes upstream: null when `client` is
* over. Only failures count, so everyone behind one office address can sign in.
*/
export function reserveDesktopHandoffPark(client: string, now = Date.now()): Promise<BudgetRefund | null> {
return desktopParkBudget.reserve(client, now);
}
/**
* Reserve one redeem attempt for `client`: null when it is over its limit,
* otherwise a refund to call when the redeem succeeded (only failures count).
+68
View File
@@ -28,6 +28,74 @@ export async function requestMobileHandoffCodeUrl(opts: {
}
}
/** The desktop app's sign-in, before its question: is this password right? Nothing is kept. */
export async function checkDesktopSignIn(opts: {
serverUrl: string;
serverId?: string;
username: string;
password: string;
}): Promise<{ ok: true } | { error: string }> {
try {
const res = await apiFetch('/api/auth/desktop-handoff/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ server_url: opts.serverUrl, server_id: opts.serverId, username: opts.username, password: opts.password }),
});
if (res.ok) return { ok: true };
const data = (await res.json().catch(() => null)) as { error?: unknown } | null;
return { error: typeof data?.error === 'string' ? data.error : 'login_failed' };
} catch {
return { error: 'network_error' };
}
}
/** The tab with an SSO sign-in the user hasn't answered went, or said no: it's not kept. */
export function discardDesktopSignIn(code: string): void {
void apiFetch('/api/auth/desktop-handoff/discard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
keepalive: true,
}).catch(() => {});
}
/**
* The user said yes: parks the sign-in for the app (/api/auth/desktop-handoff/park), as
* tokens wherever the server can give them. This browser gets no session out of it.
*/
export async function parkDesktopSignIn(opts: {
challenge: string;
state: string;
serverUrl: string;
serverId?: string;
username: string;
password: string;
totp?: string;
redirectUri: string;
}): Promise<{ code: string } | { error: string }> {
try {
const res = await apiFetch('/api/auth/desktop-handoff/park', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code_challenge: opts.challenge,
state: opts.state,
server_url: opts.serverUrl,
server_id: opts.serverId,
username: opts.username,
password: opts.password,
totp: opts.totp,
redirect_uri: opts.redirectUri,
}),
});
const data = (await res.json().catch(() => null)) as { code?: unknown; error?: unknown } | null;
if (res.ok && typeof data?.code === 'string' && data.code) return { code: data.code };
return { error: typeof data?.error === 'string' ? data.error : 'login_failed' };
} catch {
return { error: 'network_error' };
}
}
/**
* Confirm an SSO hand-off the server parked (/api/auth/handoff/confirm) and
* return the app link with its code. Null when the code is gone (it waited
+30
View File
@@ -27,6 +27,36 @@ export function mobileHandoffTarget(raw: string | null | undefined): string {
: '';
}
/**
* The desktop app's loopback listener (RFC 8252), the other kind of target. Only ever
* with a code: unlike the phone's link, it has no older builds that take the sign-in in a
* fragment, so without a valid challenge this isn't a hand-off at all. The code goes
* nowhere but this computer, whichever port a link names, and it's worthless without the
* verifier behind the challenge.
*/
const DESKTOP_TARGET = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{3,4})\/bulwark-desktop\/callback$/;
export function desktopHandoffTarget(raw: string | null | undefined): string {
if (typeof raw !== 'string') return '';
const match = DESKTOP_TARGET.exec(raw.trim());
return match && Number(match[1]) <= 65535 ? match[0] : '';
}
/** The port a desktop target names, for the question the page asks. */
export function desktopHandoffPort(target: string): string {
return DESKTOP_TARGET.exec(target)?.[1] ?? '';
}
/** "Not now": ends the app's flow at its listener, so it isn't left waiting. No code in it. */
export function desktopHandoffDeclineUrl(target: string, state: string): string {
return `${target}?${new URLSearchParams({ error: 'access_denied', state }).toString()}`;
}
/** Either kind of target, as the callback page reads it back. */
export function handoffTarget(raw: string | null | undefined): string {
return mobileHandoffTarget(raw) || desktopHandoffTarget(raw);
}
/** The URL the browser hands to the app: the target plus the fragment. */
export function buildMobileHandoffUrl(target: string, fragment: Record<string, string>): string {
return `${target}#${new URLSearchParams(fragment).toString()}`;
+4
View File
@@ -1,6 +1,7 @@
import { randomBytes } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { configManager } from '@/lib/admin/config-manager';
import { desktopServerSecret } from '@/lib/desktop/server-secrets';
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
import { readFileEnv } from '@/lib/read-file-env';
import { getStore, isSharedMode } from '@/lib/shared-store';
@@ -25,6 +26,9 @@ function sharedSecret(): string {
/** A secret the operator set: env, file, or the setup wizard's config. */
function configuredSessionSecret(): string {
const fromShell = desktopServerSecret('sessionSecret');
if (fromShell) return fromShell;
const fromEnv = process.env.SESSION_SECRET;
if (fromEnv) return fromEnv;
+6 -1
View File
@@ -6,6 +6,7 @@ import {
} from '@/lib/security/url-guard';
import { forwardedClientHeaders } from '@/lib/auth/client-address';
import { observeUpstream } from '@/lib/metrics/upstream';
import { desktopRedirectRefused } from '@/lib/desktop/server-redirect';
const VERIFY_TIMEOUT_MS = 10000;
const MAX_REDIRECTS = 3;
@@ -453,7 +454,11 @@ async function fetchVerifiedSession(
if (!location) {
throw new JmapAuthVerificationError('Failed to verify JMAP session', 502);
}
currentUrl = new URL(location, currentUrl).toString();
const next = new URL(location, currentUrl).toString();
if (desktopRedirectRefused(currentUrl, next, options.trusted === true)) {
throw new JmapAuthVerificationError('The mail server redirected to a server that has not been approved', 502);
}
currentUrl = next;
continue;
}
break;
+1
View File
@@ -56,6 +56,7 @@ const configValidators = {
loginShowSubtitle: isBoolean,
loginShowTotp: isBoolean,
loginShowTokenLogin: isBoolean,
desktopBrowserSignIn: isBoolean,
loginShowRememberMe: isBoolean,
loginShowVersion: isBoolean,
demoMode: isBoolean,
+179 -46
View File
@@ -144,79 +144,212 @@ describe('JMAPClient on an approved http server', () => {
});
describe('runDesktopBrowserSignIn', () => {
const ENDPOINTS = { authorization_endpoint: 'https://mail.example/authorize', client_id: 'registered-client', scope: 'openid email' };
const ENDPOINTS = { authorization_endpoint: 'https://mail.example/authorize', client_id: 'registered-client', scope: 'openid email', issuer: 'https://mail.example' };
const REDIRECT = 'http://127.0.0.1:51234/bulwark-desktop/callback';
function authBridge(result: unknown) {
const completeBrowserSignIn = vi.fn(async (_flowId: string, url: string) => (
typeof result === 'function' ? (result as (u: string) => unknown)(url) : result
));
const bridge = {
version: 1,
platform: 'win32',
servers: { prepare: vi.fn(async () => ({ ok: true, approved: false })), noteSession: vi.fn() },
auth: {
beginBrowserSignIn: vi.fn(async () => ({ flowId: 'f1', redirectUri: 'http://127.0.0.1:51234/bulwark-desktop/callback' })),
completeBrowserSignIn,
cancelBrowserSignIn: vi.fn(),
},
} as unknown as BulwarkDesktopBridge;
setBridge(bridge);
return bridge;
const controls = {
begin: vi.fn(async (): Promise<unknown> => ({ ok: true, flowId: 'f1', redirectUri: REDIRECT, state: 'main-state' })),
complete: vi.fn(async (_flowId: string, url: string) => (
typeof result === 'function' ? (result as (u: string) => unknown)(url) : result
)),
useScheme: vi.fn(async () => 'bulwark://sign-in/callback'),
cancel: vi.fn(),
beginPassword: vi.fn(async (): Promise<unknown> => ({ ok: true, host: 'mail.example' })),
nextPassword: vi.fn(async (): Promise<unknown> => ({ ok: false, error: 'timeout' })),
answerPassword: vi.fn(),
outcome: vi.fn(async (): Promise<unknown> => ({ ok: true, code: '', state: 'main-state' })),
};
setBridge({ version: 1, platform: 'win32', auth: { claim: () => controls } } as unknown as BulwarkDesktopBridge);
return controls;
}
const options = { serverUrl: 'https://mail.example', callbackPath: '/en/auth/callback', slot: 1, remember: true, addAccount: false };
const redeem = vi.fn(async () => true);
const tryPassword = vi.fn(async (): Promise<{ ok: true } | { ok: false; error: 'invalid_credentials' | 'totp_required' | 'other' }> => ({ ok: true }));
const options = { serverUrl: 'https://mail.example', slot: 1, remember: true, addAccount: false, fresh: false, redeem, tryPassword };
beforeEach(() => {
redeem.mockClear();
tryPassword.mockClear();
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(ENDPOINTS), { status: 200 })));
});
it('runs PKCE with the loopback redirect and parks what the callback page needs', async () => {
it('runs PKCE with state from main and the loopback redirect, and redeems here, with nothing in an address', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
let authorize = new URL('https://x');
authBridge((url: string) => {
authorize = new URL(url);
return { ok: true, code: 'the-code', state: authorize.searchParams.get('state') };
return { ok: true, code: 'the-code', state: 'main-state' };
});
let readBack = {};
redeem.mockImplementationOnce(async () => {
readBack = { client: sessionStorage.getItem('oauth_client_id'), slot: sessionStorage.getItem('oauth_cookie_slot'), remember: sessionStorage.getItem('oauth_remember') };
return true;
});
const outcome = await runDesktopBrowserSignIn(options);
expect(outcome.kind).toBe('callback');
expect(outcome).toEqual({ kind: 'signed-in' });
expect(authorize.origin + authorize.pathname).toBe('https://mail.example/authorize');
expect(authorize.searchParams.get('client_id')).toBe('registered-client');
expect(authorize.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback');
expect(authorize.searchParams.get('redirect_uri')).toBe(REDIRECT);
expect(authorize.searchParams.get('state')).toBe('main-state');
expect(authorize.searchParams.get('code_challenge_method')).toBe('S256');
expect(authorize.searchParams.get('code_challenge')).toBeTruthy();
expect(sessionStorage.getItem('oauth_redirect_uri')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback');
expect(sessionStorage.getItem('oauth_client_id')).toBe('registered-client');
expect(sessionStorage.getItem('oauth_server_url')).toBe('https://mail.example');
expect(sessionStorage.getItem('oauth_cookie_slot')).toBe('1');
expect(sessionStorage.getItem('oauth_code_verifier')).toBeTruthy();
});
it('rejects an answer with another state', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
authBridge({ ok: true, code: 'c', state: 'forged' });
const outcome = await runDesktopBrowserSignIn(options);
expect(outcome).toEqual({ kind: 'failed', error: 'state_mismatch' });
expect(readBack).toEqual({ client: 'registered-client', slot: '1', remember: 'true' });
const [server, code, verifier, redirect] = redeem.mock.calls[0] as unknown as string[];
expect([server, code, redirect]).toEqual(['https://mail.example', 'the-code', REDIRECT]);
// The verifier behind the challenge, kept in this function and nowhere else.
expect(verifier.length).toBeGreaterThanOrEqual(43);
expect(authorize.toString()).not.toContain(verifier);
expect(sessionStorage.getItem('oauth_code_verifier')).toBeNull();
expect(sessionStorage.getItem('oauth_client_id')).toBeNull();
});
it('sends nothing to a server the user declined', async () => {
it('refuses an answer another issuer signed', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
const bridge = authBridge({ ok: false, error: 'cancelled' });
bridge.servers!.prepare = vi.fn(async () => ({ ok: false as const, reason: 'declined' as const }));
const outcome = await runDesktopBrowserSignIn(options);
expect(outcome.kind).toBe('declined');
expect(bridge.auth!.beginBrowserSignIn).not.toHaveBeenCalled();
authBridge({ ok: true, code: 'c', state: 'main-state', iss: 'https://evil.example' });
expect(await runDesktopBrowserSignIn(options)).toEqual({ kind: 'failed', error: 'issuer_mismatch' });
expect(redeem).not.toHaveBeenCalled();
});
it('asks the provider for a new login when the app says one is due', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
let authorize = new URL('https://x');
authBridge((url: string) => {
authorize = new URL(url);
return { ok: false, error: 'cancelled' };
});
await runDesktopBrowserSignIn({ ...options, fresh: true });
expect(authorize.searchParams.get('prompt')).toBe('login');
});
it.each(['declined', 'policy', 'invalid'])('takes the no from main: %s, and nothing is asked of the server', async (error) => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
const controls = authBridge({ ok: false, error: 'cancelled' });
controls.begin.mockResolvedValueOnce({ ok: false, error });
expect((await runDesktopBrowserSignIn(options)).kind).toBe(error);
expect(controls.complete).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it('says so when the server has no OAuth, and closes the listener', async () => {
it('a timeout is its own answer, so the page can say try again', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 })));
const bridge = authBridge({ ok: true });
const outcome = await runDesktopBrowserSignIn(options);
expect(outcome.kind).toBe('unavailable');
expect(bridge.auth!.cancelBrowserSignIn).toHaveBeenCalledWith('f1');
expect(bridge.auth!.completeBrowserSignIn).not.toHaveBeenCalled();
authBridge({ ok: false, error: 'timeout' });
expect((await runDesktopBrowserSignIn(options)).kind).toBe('timeout');
});
it('a discovery outage is a failure, not the password page', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 503 })));
const controls = authBridge({ ok: true });
expect((await runDesktopBrowserSignIn(options)).kind).toBe('failed');
expect(controls.cancel).toHaveBeenCalledWith('f1');
expect(controls.beginPassword).not.toHaveBeenCalled();
});
it('a server with no OAuth gets the page main serves, and each post goes through the login of the app', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: 'no_oauth' }), { status: 404 })));
const controls = authBridge({ ok: true });
controls.nextPassword
.mockResolvedValueOnce({ ok: true, username: 'a', password: 'wrong' })
.mockResolvedValueOnce({ ok: true, username: 'a', password: 'right', totp: '123456' });
tryPassword.mockResolvedValueOnce({ ok: false, error: 'invalid_credentials' }).mockResolvedValueOnce({ ok: true });
expect(await runDesktopBrowserSignIn(options)).toEqual({ kind: 'signed-in' });
expect(controls.complete).not.toHaveBeenCalled();
expect(tryPassword.mock.calls.map((call) => (call as unknown[])[0])).toEqual([
{ ok: true, username: 'a', password: 'wrong' },
{ ok: true, username: 'a', password: 'right', totp: '123456' },
]);
expect(controls.answerPassword.mock.calls).toEqual([
['f1', { ok: false, error: 'invalid_credentials' }],
['f1', { ok: true }],
]);
});
it('a server that refuses loopback goes by the scheme', async () => {
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ...ENDPOINTS, use_scheme: true }), { status: 200 })));
let authorize = new URL('https://x');
const controls = authBridge((url: string) => {
authorize = new URL(url);
return { ok: true, code: 'c', state: 'main-state' };
});
await runDesktopBrowserSignIn(options);
expect(controls.useScheme).toHaveBeenCalledWith('f1');
expect(authorize.searchParams.get('redirect_uri')).toBe('bulwark://sign-in/callback');
expect((redeem.mock.calls[0] as unknown as string[])[3]).toBe('bulwark://sign-in/callback');
});
});
describe('runInstanceBrowserSignIn', () => {
const REDIRECT = 'http://127.0.0.1:51234/bulwark-desktop/callback';
function bridgeWith(complete: (flowId: string, url: string) => unknown) {
const controls = {
begin: vi.fn(async () => ({ ok: true, flowId: 'f1', redirectUri: REDIRECT, state: 'main-state' })),
complete: vi.fn(async (flowId: string, url: string) => complete(flowId, url)),
cancel: vi.fn(),
};
setBridge({ version: 1, platform: 'win32', auth: { claim: () => controls } } as unknown as BulwarkDesktopBridge);
return controls;
}
it('opens the login page of the instance with no secret in the address, and hands back the verifier', async () => {
const { runInstanceBrowserSignIn } = await import('../browser-sign-in');
let opened = new URL('https://x');
bridgeWith((_flow, url) => {
opened = new URL(url);
return { ok: true, code: 'the-code', state: 'main-state' };
});
const outcome = await runInstanceBrowserSignIn({ base: 'https://bulwark.example/mail', locale: 'de', fresh: false });
expect(opened.origin + opened.pathname).toBe('https://bulwark.example/mail/de/login');
expect(opened.searchParams.get('mobile_redirect_uri')).toBe(REDIRECT);
expect(opened.searchParams.get('mobile_state')).toBe('main-state');
expect(opened.searchParams.get('mobile_code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(opened.searchParams.has('fresh_login')).toBe(false);
if (outcome.kind !== 'code') throw new Error(outcome.kind);
expect(outcome.code).toBe('the-code');
expect(opened.toString()).not.toContain(outcome.verifier);
const challenge = Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(outcome.verifier))).toString('base64url');
expect(opened.searchParams.get('mobile_code_challenge')).toBe(challenge);
});
it('passes on that a new login is due, and takes a timeout as a timeout', async () => {
const { runInstanceBrowserSignIn } = await import('../browser-sign-in');
let opened = new URL('https://x');
bridgeWith((_flow, url) => {
opened = new URL(url);
return { ok: false, error: 'timeout' };
});
expect((await runInstanceBrowserSignIn({ base: 'https://b.example', locale: 'en', fresh: true })).kind).toBe('timeout');
expect(opened.searchParams.get('fresh_login')).toBe('1');
});
});
describe('who gets the sign-in calls', () => {
it('claims them once per bridge, whatever asks after that', async () => {
const { claimBrowserSignIn } = await import('../browser-sign-in');
const controls = {};
const claim = vi.fn(() => controls);
setBridge({ version: 1, platform: 'win32', auth: { claim } } as unknown as BulwarkDesktopBridge);
expect(claimBrowserSignIn()).toBe(controls);
expect(claimBrowserSignIn()).toBe(controls);
expect(claim).toHaveBeenCalledTimes(1);
});
it('offers nothing in a browser, under an older shell, or with the policy off', async () => {
const { browserSignInAvailable } = await import('../browser-sign-in');
setBridge(undefined);
expect(browserSignInAvailable()).toBe(false);
setBridge({ version: 1, platform: 'win32' } as unknown as BulwarkDesktopBridge);
expect(browserSignInAvailable()).toBe(false);
const claim = () => ({});
setBridge({ version: 1, platform: 'win32', auth: { claim } } as unknown as BulwarkDesktopBridge);
expect(browserSignInAvailable()).toBe(true);
const { EMPTY_POLICY_LAYERS, resolvePolicies } = await import('../policy-schema');
const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { BrowserSignIn: false } });
setBridge({ version: 1, platform: 'win32', auth: { claim }, policy: { get: () => snapshot, onChange: () => () => {} } } as unknown as BulwarkDesktopBridge);
expect(browserSignInAvailable()).toBe(false);
});
});
@@ -0,0 +1,119 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
/**
* What the desktop app asks before it sends someone to a server's own sign-in page: the
* authorize endpoint and a client id, and which redirect that server will take.
*/
vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } }));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { ensureLoaded: async () => {}, get: (key: string, fallback: unknown) => (key === 'allowCustomJmapEndpoint' ? true : fallback) },
}));
vi.mock('@/lib/oauth/custom-upstream', () => ({
resolveCustomUpstream: async (serverUrl: string, clientId: string) => (serverUrl.startsWith('https://') ? { serverUrl, clientId, trusted: false } : null),
}));
const discoverOAuth = vi.hoisted(() => vi.fn());
vi.mock('@/lib/oauth/discovery', () => ({ discoverOAuth }));
vi.mock('@/lib/oauth/token-exchange', () => ({ getDiscoveryValidator: () => undefined }));
const SERVER = 'https://mail.example';
const REDIRECT = 'http://127.0.0.1:51234/bulwark-desktop/callback';
const METADATA = { issuer: SERVER, authorization_endpoint: `${SERVER}/login`, token_endpoint: `${SERVER}/auth/token` };
let registrations: { url: string; redirect: string }[];
let answer: (redirect: string) => Response;
function post(body: unknown) {
return new NextRequest(`https://app.bulwark.localhost/api/auth/desktop-oauth`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },
body: JSON.stringify(body),
});
}
async function route() {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
vi.resetModules();
return (await import('@/app/api/auth/desktop-oauth/route')).POST;
}
beforeEach(() => {
registrations = [];
discoverOAuth.mockReset().mockResolvedValue(METADATA);
answer = () => new Response(JSON.stringify({ client_id: 'swc1.abc' }), { status: 201 });
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const redirect = (JSON.parse(String(init?.body)) as { redirect_uris: string[] }).redirect_uris[0];
registrations.push({ url: String(input), redirect });
return answer(redirect);
}));
});
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe('/api/auth/desktop-oauth', () => {
it('registers the loopback redirect, with no port: servers ignore the port', async () => {
const POST = await route();
const res = await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }));
expect(await res.json()).toMatchObject({ authorization_endpoint: `${SERVER}/login`, client_id: 'swc1.abc', issuer: SERVER });
expect(registrations).toEqual([{ url: `${SERVER}/auth/register`, redirect: 'http://127.0.0.1/bulwark-desktop/callback' }]);
});
it('uses the registration endpoint the server names', async () => {
discoverOAuth.mockResolvedValue({ ...METADATA, registration_endpoint: `${SERVER}/oauth/clients` });
const POST = await route();
await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }));
expect(registrations[0].url).toBe(`${SERVER}/oauth/clients`);
});
it('says so when a server has no OAuth', async () => {
discoverOAuth.mockResolvedValue(null);
const POST = await route();
const res = await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }));
expect(res.status).toBe(404);
expect(await res.json()).toEqual({ error: 'no_oauth' });
});
it('a discovery outage is not the same answer', async () => {
discoverOAuth.mockRejectedValue(new Error('down'));
const POST = await route();
expect((await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }))).status).toBe(503);
});
it('with registration off, sends the fixed id and the loopback redirect', async () => {
answer = () => new Response('{}', { status: 401 });
const POST = await route();
const body = await (await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }))).json();
expect(body.client_id).toBe('bulwark-desktop');
expect(body.use_scheme).toBeUndefined();
expect(registrations).toHaveLength(1);
});
it('takes the scheme only from a server that refuses loopback by name and accepts the scheme', async () => {
answer = (redirect) => (redirect.startsWith('bulwark:')
? new Response(JSON.stringify({ client_id: 'schemed' }), { status: 201 })
: new Response(JSON.stringify({ error: 'invalid_redirect_uri' }), { status: 400 }));
const POST = await route();
const body = await (await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }))).json();
expect(body).toMatchObject({ client_id: 'schemed', use_scheme: true });
expect(registrations.map((r) => r.redirect)).toEqual(['http://127.0.0.1/bulwark-desktop/callback', 'bulwark://sign-in/callback']);
});
it('stays on loopback when the server refuses both', async () => {
answer = () => new Response(JSON.stringify({ error: 'invalid_redirect_uri' }), { status: 400 });
const POST = await route();
const body = await (await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }))).json();
expect(body.client_id).toBe('bulwark-desktop');
expect(body.use_scheme).toBeUndefined();
});
it.each(['http://localhost:51234/bulwark-desktop/callback', 'https://evil.example/bulwark-desktop/callback', 'bulwark://sign-in/callback'])('refuses %s as the redirect', async (redirectUri) => {
const POST = await route();
expect((await POST(post({ serverUrl: SERVER, redirectUri }))).status).toBe(400);
expect(registrations).toEqual([]);
});
});
@@ -0,0 +1,279 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const policy = vi.hoisted(() => ({ decided: true, download: true, export: true }));
vi.mock('@/lib/device-policy', () => ({
canDownload: () => policy.download,
canExport: () => policy.export,
}));
vi.mock('@/lib/offline/offline-mode', () => ({
scopeDecided: () => policy.decided,
}));
import {
canEditFile,
editAllowed,
editFileInfo,
editForbidden,
followEditSessions,
parkFilesForReply,
parseEditSource,
readEditedFile,
resetEditSessions,
startAttachmentEdit,
takeFilesForReply,
useEditSessions,
type StartEditInput,
} from '../edit-attachments';
import { setAppLockCheck } from '../app-lock-state';
import { runWipeCleaners } from '@/lib/offline/wipe-registry';
import { runBeforeSignOut } from '@/lib/sign-out-cleanup';
function fakeBridge() {
return {
canEdit: vi.fn((name: string) => !name.endsWith('.exe')),
begin: vi.fn(async (_key: string) => ({ ok: true as const, ticket: 't1' } as { ok: true; ticket: string; open?: string } | { ok: false; reason: 'locked' })),
open: vi.fn(async () => ({ ok: true as const, id: 's1' })),
reopen: vi.fn(async () => ({ ok: true as const, id: 's1' })),
list: vi.fn(async () => []),
settle: vi.fn(async () => []),
read: vi.fn(async () => ({ ok: true as const, name: 'a.docx', type: 'x/y', data: new Uint8Array([1, 2]), hash: 'h' })),
taken: vi.fn(async () => {}),
release: vi.fn(async () => {}),
discard: vi.fn(async () => ({ held: [] as string[] })),
forceDiscard: vi.fn(async () => ({ held: [] as string[] })),
askClear: vi.fn(async () => true),
clear: vi.fn(async () => ({ held: [] as string[] })),
wipe: vi.fn(async () => ({ held: [] as string[] })),
shown: vi.fn(),
setSource: vi.fn(),
claim: vi.fn(),
onChange: vi.fn(() => () => {}),
};
}
let bridge: ReturnType<typeof fakeBridge>;
const input = (over: Partial<StartEditInput> = {}): StartEditInput => ({
key: 'k',
name: 'a.docx',
type: 'x/y',
account: 'acct',
source: { kind: 'mail', emailId: 'e1' },
load: vi.fn(async () => new Uint8Array([9]).buffer),
...over,
});
beforeEach(() => {
policy.decided = true;
policy.download = true;
policy.export = true;
bridge = fakeBridge();
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, edit: bridge };
resetEditSessions();
setAppLockCheck(null);
});
afterEach(() => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
describe('the policy', () => {
it('takes both saving attachments and export', () => {
expect(editAllowed('acct')).toBe(true);
policy.download = false;
expect(editAllowed('acct')).toBe(false);
policy.download = true;
policy.export = false;
expect(editAllowed('acct')).toBe(false);
});
it('counts an unknown policy as no for starting, and as no reason to delete anything', () => {
policy.decided = false;
expect(editAllowed('acct')).toBe(false);
expect(editForbidden('acct')).toBe(false);
policy.decided = true;
policy.export = false;
expect(editForbidden('acct')).toBe(true);
});
});
describe('starting', () => {
it('asks main from the click, then fetches, then opens with what it fetched', async () => {
const order: string[] = [];
bridge.begin.mockImplementation(async () => {
order.push('begin');
return { ok: true, ticket: 't1' };
});
const start = input({ load: async () => {
order.push('load');
return new Uint8Array([7, 8]).buffer;
} });
bridge.open.mockImplementation(async () => {
order.push('open');
return { ok: true, id: 's1' };
});
expect(await startAttachmentEdit(start)).toEqual({ ok: true, id: 's1' });
expect(order).toEqual(['begin', 'load', 'open']);
const [ticket, file] = bridge.open.mock.calls[0] as unknown as [string, { data: Uint8Array; source: string; account: string; key: string }];
expect(ticket).toBe('t1');
expect([...file.data]).toEqual([7, 8]);
expect(file).toMatchObject({ account: 'acct', key: 'k' });
expect(JSON.parse(file.source)).toEqual({ kind: 'mail', emailId: 'e1' });
});
it('touches nothing when the organisation says no', async () => {
policy.download = false;
const start = input();
expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'policy' });
expect(bridge.begin).not.toHaveBeenCalled();
expect(start.load).not.toHaveBeenCalled();
});
it('stops when the answer changes while the bytes are on their way, as a guarded sink does', async () => {
const start = input({ load: async () => {
policy.export = false;
return new Uint8Array([1]).buffer;
} });
expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'policy' });
expect(bridge.open).not.toHaveBeenCalled();
});
it('stops when the app locks while the bytes are on their way', async () => {
const start = input({ load: async () => {
setAppLockCheck(() => true);
return new Uint8Array([1]).buffer;
} });
expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'locked' });
expect(bridge.open).not.toHaveBeenCalled();
});
it('opens the same file again and fetches nothing, so the edits stay', async () => {
bridge.begin.mockResolvedValue({ ok: true, ticket: 't2', open: 's9' });
const start = input();
await startAttachmentEdit(start);
expect(bridge.reopen).toHaveBeenCalledWith('t2', 's9');
expect(start.load).not.toHaveBeenCalled();
expect(bridge.open).not.toHaveBeenCalled();
});
it('passes on what main refuses', async () => {
bridge.begin.mockResolvedValue({ ok: false, reason: 'locked' });
const start = input();
expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'locked' });
expect(start.load).not.toHaveBeenCalled();
});
it('asks the shell which files may go, and answers no without a shell', () => {
expect(canEditFile('a.docx')).toBe(true);
expect(canEditFile('a.exe')).toBe(false);
expect(canEditFile(null)).toBe(false);
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
expect(canEditFile('a.docx')).toBe(false);
});
it('does nothing in a browser', async () => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
const start = input();
expect(await startAttachmentEdit(start)).toEqual({ ok: false, reason: 'unavailable' });
expect(start.load).not.toHaveBeenCalled();
});
});
describe('reading a saved file back', () => {
it('refuses when the organisation has said no since', async () => {
policy.export = false;
expect(await readEditedFile({ id: 's1', account: 'acct' })).toEqual({ ok: false, reason: 'policy' });
expect(bridge.read).not.toHaveBeenCalled();
});
it('gives a file that remembers its session and version', async () => {
const outcome = await readEditedFile({ id: 's1', account: 'acct' });
if (!outcome.ok) throw new Error('refused');
expect(outcome.file.name).toBe('a.docx');
expect(outcome.file.type).toBe('x/y');
expect(new Uint8Array(await outcome.file.arrayBuffer())).toEqual(new Uint8Array([1, 2]));
expect(editFileInfo(outcome.file)).toEqual({ id: 's1', hash: 'h' });
expect(editFileInfo(new File([], 'other'))).toBeUndefined();
});
it('passes a held or lost file on as such', async () => {
bridge.read.mockResolvedValueOnce({ ok: false, reason: 'busy' } as never);
expect(await readEditedFile({ id: 's1', account: 'acct' })).toEqual({ ok: false, reason: 'busy' });
});
});
describe('a file on its way to a reply', () => {
it('waits for that message, once', () => {
const file = new File([], 'a.txt');
parkFilesForReply('e1', [file]);
expect(takeFilesForReply('e2')).toEqual([]);
expect(takeFilesForReply('e1')).toEqual([file]);
expect(takeFilesForReply('e1')).toEqual([]);
});
it('does not wait for ever', () => {
vi.useFakeTimers();
try {
parkFilesForReply('e1', [new File([], 'a.txt')]);
vi.advanceTimersByTime(3 * 60 * 1000);
expect(takeFilesForReply('e1')).toEqual([]);
} finally {
vi.useRealTimers();
}
});
});
describe('what main says', () => {
it('follows the list once per page', async () => {
const session = { id: 's1', name: 'a', type: '', account: 'x', source: '', changed: true, version: 1, hash: 'h', claimed: false };
bridge.list.mockResolvedValue([session] as never);
followEditSessions();
followEditSessions();
expect(bridge.onChange).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(useEditSessions.getState().sessions).toEqual([session]));
(bridge.onChange.mock.calls[0] as unknown as [(s: unknown[]) => void])[0]([]);
expect(useEditSessions.getState().sessions).toEqual([]);
});
});
describe('where a source came from', () => {
it('reads what it wrote, and treats anything else as just a file', () => {
expect(parseEditSource(JSON.stringify({ kind: 'mail', emailId: 'e', ownerAccountId: 'o' }))).toEqual({ kind: 'mail', emailId: 'e', ownerAccountId: 'o' });
expect(parseEditSource(JSON.stringify({ kind: 'draft', draftId: 'd' }))).toEqual({ kind: 'draft', draftId: 'd' });
expect(parseEditSource(JSON.stringify({ kind: 'draft', draftId: null }))).toEqual({ kind: 'draft', draftId: null });
expect(parseEditSource(JSON.stringify({ kind: 'mail' }))).toEqual({ kind: 'file' });
expect(parseEditSource('not json')).toEqual({ kind: 'file' });
});
});
describe('the copies go with the account', () => {
it('on a wipe of an account, and of everything, with no question', async () => {
await expect(runWipeCleaners('acct')).resolves.toBe(true);
expect(bridge.wipe).toHaveBeenCalledWith('acct');
bridge.wipe.mockClear();
await runWipeCleaners(null);
expect(bridge.wipe).toHaveBeenCalledWith(null);
expect(bridge.clear).not.toHaveBeenCalled();
});
it('a wipe with a copy a program holds does not count as done', async () => {
bridge.wipe.mockResolvedValueOnce({ held: ['a.docx'] });
await expect(runWipeCleaners('acct')).resolves.toBe(false);
});
it('at sign-out', async () => {
await runBeforeSignOut('acct');
expect(bridge.clear).toHaveBeenCalledWith('acct');
});
it("not when the app ended the session itself and keeps the account's data", async () => {
await runBeforeSignOut('acct', { keepLocalStore: true });
expect(bridge.clear).not.toHaveBeenCalled();
});
it('a cleaner with no shell is no failure', async () => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
await expect(runWipeCleaners(null)).resolves.toBe(true);
});
});
+11
View File
@@ -228,6 +228,17 @@ describe('a toast\'s buttons', () => {
stop();
});
it('a toast for a saved attachment only brings the window up, and leaves the page where it is', async () => {
const { setMailLinkOpener } = await import('../mail-links');
const opener = vi.fn();
const stop = setMailLinkOpener(opener);
await run({ type: 'notification', action: 'open', context: JSON.stringify({ editedFile: true }) });
expect(focus).toHaveBeenCalled();
expect(opener).not.toHaveBeenCalled();
expect(leaveFor).not.toHaveBeenCalled();
stop();
});
it('a click on a summary toast opens the Inbox of that login', async () => {
const { setMailLinkOpener } = await import('../mail-links');
const opener = vi.fn();
+1 -1
View File
@@ -30,7 +30,7 @@ describe('the schema', () => {
'InstanceUrl', 'AllowStandalone', 'AllowedJmapServers', 'AllowInsecureInstance', 'UpdateChannel', 'AutoUpdate',
'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed',
'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist',
'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'WipeOfflineData',
'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'BrowserSignIn', 'WipeOfflineData',
]);
for (const key of POLICY_KEYS) {
const { title, description } = POLICY_SCHEMA[key];
@@ -0,0 +1,200 @@
// @vitest-environment node
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* An approved server sits inside the SSRF guard, so where it redirects to is the
* whole question. Real sockets: what counts is who received the request, and
* whether it carried the user's credentials.
*/
const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins');
const POLICY_KEY = Symbol.for('bulwark.desktop.serverPolicy');
const holder = globalThis as unknown as Record<symbol, unknown>;
const AUTH = `Basic ${Buffer.from('probe@example.org:not-a-real-password').toString('base64')}`;
interface Host {
origin: string;
hits: { url: string; auth: boolean }[];
answer: (req: IncomingMessage, res: ServerResponse) => void;
}
const servers: Server[] = [];
function session(res: ServerResponse) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ apiUrl: '/jmap/', accounts: { a: {} }, primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, username: 'probe@example.org' }));
}
async function host(answer: Host['answer'] = (_req, res) => session(res)): Promise<Host> {
const self: Host = { origin: '', hits: [], answer };
const server = createServer((req, res) => {
self.hits.push({ url: req.url ?? '', auth: !!req.headers.authorization });
self.answer(req, res);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
servers.push(server);
self.origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
return self;
}
const redirectTo = (location: string, status = 307): Host['answer'] => (_req, res) => {
res.writeHead(status, { location });
res.end();
};
function approve(...origins: string[]) {
holder[TRUST_KEY] = new Set(origins);
}
beforeEach(() => {
vi.resetModules();
delete holder[TRUST_KEY];
delete holder[POLICY_KEY];
});
afterEach(async () => {
vi.unstubAllEnvs();
delete holder[TRUST_KEY];
delete holder[POLICY_KEY];
await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))));
});
async function verify(server: string) {
const { fetchJmapSessionIdentity } = await import('@/lib/auth/verify-jmap-auth');
return fetchJmapSessionIdentity(server, AUTH, { trusted: true });
}
async function fetchVia(url: string, init: RequestInit = {}) {
const { fetchJmapServer } = await import('@/lib/stalwart/server-fetch');
return fetchJmapServer(url, { headers: { Authorization: AUTH }, ...init }, true);
}
describe('the desktop build, signing in to an approved server', () => {
beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'));
it('follows a redirect on the same origin, as Stalwart answers /.well-known/jmap', async () => {
const a = await host((req, res) => (req.url === '/.well-known/jmap' ? redirectTo('/jmap/session')(req, res) : session(res)));
approve(a.origin);
await expect(verify(a.origin)).resolves.toBeTruthy();
expect(a.hits).toEqual([{ url: '/.well-known/jmap', auth: true }, { url: '/jmap/session', auth: true }]);
});
it('refuses a redirect to a server nobody approved, which gets nothing', async () => {
const b = await host();
const a = await host(redirectTo(''));
a.answer = redirectTo(`${b.origin}/.well-known/jmap`);
approve(a.origin);
await expect(verify(a.origin)).rejects.toMatchObject({ name: 'JmapAuthVerificationError', status: 502 });
expect(b.hits).toEqual([]);
});
it('counts another name for the same machine as another server', async () => {
const b = await host();
const a = await host(redirectTo(`${b.origin.replace('127.0.0.1', 'localhost')}/x`));
approve(a.origin);
await expect(verify(a.origin)).rejects.toMatchObject({ status: 502 });
expect(b.hits).toEqual([]);
});
it('follows one to another approved server', async () => {
const b = await host();
const a = await host(redirectTo(`${b.origin}/jmap/session`));
approve(a.origin, b.origin);
await expect(verify(a.origin)).resolves.toBeTruthy();
expect(b.hits).toEqual([{ url: '/jmap/session', auth: true }]);
});
it('holds AllowedJmapServers across the redirect, even to an approved server', async () => {
const b = await host();
const a = await host(redirectTo(`${b.origin}/jmap/session`));
approve(a.origin, b.origin);
holder[POLICY_KEY] = { pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: [a.origin] };
await expect(verify(a.origin)).rejects.toMatchObject({ status: 502 });
expect(b.hits).toEqual([]);
});
});
describe('the desktop build, other requests to an approved server', () => {
beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'));
it('follows the same origin', async () => {
const a = await host((req, res) => (req.url === '/.well-known/jmap' ? redirectTo('/jmap/session')(req, res) : session(res)));
approve(a.origin);
expect((await fetchVia(`${a.origin}/.well-known/jmap`)).status).toBe(200);
expect(a.hits.map((hit) => hit.auth)).toEqual([true, true]);
});
it('refuses a server nobody approved, which gets nothing', async () => {
const b = await host();
const a = await host(redirectTo(`${b.origin}/x`));
approve(a.origin);
await expect(fetchVia(`${a.origin}/y`)).rejects.toThrow();
expect(b.hits).toEqual([]);
});
it('still hands back the redirect when asked to', async () => {
const b = await host();
const a = await host(redirectTo(`${b.origin}/x`));
approve(a.origin);
expect((await fetchVia(`${a.origin}/y`, { redirect: 'manual' })).status).toBe(307);
expect(b.hits).toEqual([]);
});
it('postJmap keeps to the approved set too', async () => {
const b = await host();
const a = await host(redirectTo(`https://127.0.0.1:${new URL(b.origin).port}/jmap/`));
approve(a.origin);
const { postJmap } = await import('@/lib/stalwart/jmap-api');
await expect(postJmap(`${a.origin}/jmap`, AUTH, '{}', { trusted: true })).rejects.toThrow(/untrusted host/);
expect(b.hits).toEqual([]);
});
});
describe('which hops count', () => {
beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'));
async function refused(from: string, to: string, approved = true) {
const { desktopRedirectRefused } = await import('../server-redirect');
return desktopRedirectRefused(from, to, approved);
}
it('lets the same host move up to https on the standard ports only', async () => {
expect(await refused('http://mail.lan/x', 'https://mail.lan/x')).toBe(false);
expect(await refused('http://mail.lan:8080/x', 'https://mail.lan/x')).toBe(true);
expect(await refused('http://mail.lan/x', 'https://mail.lan:8443/x')).toBe(true);
expect(await refused('http://mail.lan/x', 'https://other.lan/x')).toBe(true);
expect(await refused('https://mail.lan/x', 'http://mail.lan/x')).toBe(true);
});
it('leaves a public server to the hop-by-hop guard, but not to the policy', async () => {
expect(await refused('https://a.example/x', 'https://b.example/x', false)).toBe(false);
holder[POLICY_KEY] = { pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: ['https://a.example'] };
expect(await refused('https://a.example/x', 'https://b.example/x', false)).toBe(true);
expect(await refused('https://a.example/x', 'https://a.example/y', false)).toBe(false);
});
});
describe('a server install', () => {
it('follows an admin-configured server as it always did', async () => {
const b = await host();
const a = await host(redirectTo(`${b.origin}/jmap/session`));
approve(a.origin);
await expect(verify(a.origin)).resolves.toBeTruthy();
expect(b.hits).toEqual([{ url: '/jmap/session', auth: true }]);
});
it('other requests too, where fetch takes the credentials off', async () => {
const b = await host();
const a = await host(redirectTo(`${b.origin}/x`));
expect((await fetchVia(`${a.origin}/y`)).status).toBe(200);
expect(b.hits).toEqual([{ url: '/x', auth: false }]);
});
it('has no opinion on any hop', async () => {
const { desktopRedirectRefused } = await import('../server-redirect');
approve();
holder[POLICY_KEY] = { pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: [] };
expect(desktopRedirectRefused('http://a.lan/', 'http://169.254.169.254/', true)).toBe(false);
});
});
@@ -0,0 +1,57 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* The desktop app's server gets its secrets by message, so they stay out of its
* environment block. No other build looks there.
*/
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { get: (_key: string, fallback: unknown) => fallback },
}));
vi.mock('@/lib/shared-store', () => ({ isSharedMode: () => false, getStore: () => ({}) }));
vi.mock('@/lib/admin/paths', () => ({ ensureStateDir: async () => {}, getStatePath: () => 'C:/nowhere/.session-secret' }));
const KEY = Symbol.for('bulwark.desktop.serverSecrets');
const holder = globalThis as unknown as Record<symbol, unknown>;
const FROM_SHELL = 'from-the-shell-from-the-shell-from-the-shell';
async function sessionSecret() {
vi.resetModules();
return (await import('@/lib/auth/session-secret')).getSessionSecret();
}
beforeEach(() => {
holder[KEY] = { sessionSecret: FROM_SHELL, adminPassword: 'admin-from-the-shell' };
});
afterEach(() => {
vi.unstubAllEnvs();
delete holder[KEY];
});
describe('the session secret', () => {
it('comes from the shell in the desktop build, with nothing in the environment', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
vi.stubEnv('SESSION_SECRET', '');
expect(await sessionSecret()).toBe(FROM_SHELL);
});
it('never from there in a server build', async () => {
vi.stubEnv('SESSION_SECRET', '');
expect(await sessionSecret()).toBe('');
vi.stubEnv('SESSION_SECRET', 'from-the-environment');
expect(await sessionSecret()).toBe('from-the-environment');
});
});
describe('the admin password', () => {
it('is only readable from the shell in the desktop build', async () => {
const { desktopServerSecret } = await import('../server-secrets');
expect(desktopServerSecret('adminPassword')).toBe('');
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect(desktopServerSecret('adminPassword')).toBe('admin-from-the-shell');
delete holder[KEY];
expect(desktopServerSecret('adminPassword')).toBe('');
});
});
+37 -1
View File
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { build } from 'esbuild';
import { desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs';
import { DESKTOP_TRUST_MARKER, desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs';
const ROOT = path.resolve(__dirname, '../../..');
const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins');
@@ -112,6 +112,42 @@ describe('the flag is fixed at build time', () => {
}
});
/** Compiles the trust check alone, kept as an export the way Turbopack keeps it. */
async function trustCheckOfBuild(flag: string): Promise<string> {
const result = await build({
entryPoints: [path.join(ROOT, 'lib/desktop/trusted-servers.ts')],
bundle: true,
write: false,
minify: true,
format: 'esm',
platform: 'node',
define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(flag) },
});
return result.outputFiles[0].text;
}
it('a server build holds no trust path, even when a bundler keeps the unused export', async () => {
expect(await trustCheckOfBuild('')).not.toContain(DESKTOP_TRUST_MARKER);
});
it('a desktop build still holds it', async () => {
expect(await trustCheckOfBuild('1')).toContain(DESKTOP_TRUST_MARKER);
});
it('the secrets from the shell are read in the desktop build only', async () => {
const compile = async (flag: string) => (await build({
entryPoints: [path.join(ROOT, 'lib/desktop/server-secrets.ts')],
bundle: true,
write: false,
minify: true,
format: 'esm',
platform: 'node',
define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(flag) },
})).outputFiles[0].text;
expect(await compile('')).not.toContain('bulwark.desktop.serverSecrets');
expect(await compile('1')).toContain('bulwark.desktop.serverSecrets');
});
it('no image build sets it', () => {
for (const file of ['Dockerfile', 'Dockerfile.lite']) {
expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP');
+1 -1
View File
@@ -3,7 +3,7 @@
* hand-off both hand out `https://app.bulwark.localhost`, which nothing else
* can reach, and the app has no use for impersonation. proxy.ts answers 404.
*/
const BLOCKED = /^\/api\/auth\/(pair|handoff|impersonate)(\/|$)/;
const BLOCKED = /^\/api\/auth\/(pair|handoff|desktop-handoff|impersonate)(\/|$)/;
export function isDesktopBlockedRoute(pathname: string): boolean {
return BLOCKED.test(pathname);
+154 -48
View File
@@ -1,104 +1,210 @@
/**
* Sign-in through the system browser in the desktop build, for any Stalwart.
* "Sign in with your browser" in the desktop app, so a password manager, passkeys and an
* organisation's sign-in all work where they live.
*
* A window on app.bulwark.localhost can't be a redirect target: Stalwart 1.0
* refuses it for a public client, and identity providers won't embed. So the
* Electron main process listens on http://127.0.0.1:<port> for one redirect and
* hands the code back. The rest is the regular PKCE flow: the same
* sessionStorage keys, then the callback page, which reads the stored redirect
* URI because it can't be rebuilt from this page's origin.
* Standalone: RFC 8252. The mail server's own authorize page in the system browser, with
* the redirect going to a listener in the Electron main process, and this page redeeming
* the code with the PKCE verifier it kept. A server with no OAuth at all gets a small page
* main serves on a stable loopback port, and what's posted there comes down the app's
* normal login path here.
*
* Google and Entra can't work here. Their clients are registered by whoever
* owns them, and in standalone mode nobody does. Those need an instance.
* Instance: the instance's real login page at its real origin, so saved passwords match.
* It parks the sign-in on the instance and sends a one-time code to the same kind of
* listener (lib/auth/mobile-handoff.ts). The page redeems it with the verifier.
*
* The calls are taken once per document, in the first render (claimBrowserSignIn, from
* WindowControlsHost): a same-origin frame can reach the bridge, and main can't tell.
*
* Google and Entra can't work in standalone. Their clients are registered by whoever owns
* them, and there nobody does. Those need an instance.
*/
import { apiFetch } from '@/lib/browser-navigation';
import { generateCodeChallenge, generateCodeVerifier, generateState } from '@/lib/oauth/pkce';
import { IS_LITE } from '@/lib/lite';
import { generateCodeChallenge, generateCodeVerifier } from '@/lib/oauth/pkce';
import { getDesktopBridge } from './bridge';
import { desktopPolicyValue } from './policy';
import type { DesktopBrowserSignInControls, DesktopPasswordSignInAnswer, DesktopPasswordSignInAttempt } from './types';
export const DESKTOP_REDIRECT_URI_KEY = 'oauth_redirect_uri';
export const DESKTOP_CLIENT_ID_KEY = 'oauth_client_id';
export type DesktopBrowserSignInOutcome =
| { kind: 'callback'; callbackUrl: string }
| { kind: 'unavailable' }
| { kind: 'signed-in' }
/** Instance mode: what to redeem. */
| { kind: 'code'; code: string; verifier: string }
| { kind: 'declined' }
| { kind: 'invalid' }
| { kind: 'policy' }
| { kind: 'cancelled' }
/** The user said no in the browser. */
| { kind: 'denied' }
| { kind: 'timeout' }
| { kind: 'no_browser' }
| { kind: 'failed'; error: string };
interface DesktopOAuthAnswer {
authorization_endpoint: string;
client_id: string;
scope: string;
issuer?: string;
use_scheme?: boolean;
}
let controls: DesktopBrowserSignInControls | null = null;
let claimedFrom: unknown;
let currentFlow: string | null = null;
/** Before any frame exists. Whoever comes after this gets nothing from the preload. */
export function claimBrowserSignIn(): DesktopBrowserSignInControls | null {
const bridge = getDesktopBridge();
if (claimedFrom !== bridge) {
claimedFrom = bridge;
controls = bridge?.auth?.claim?.() ?? null;
}
return controls;
}
/** Whether this page offers it. In a browser there's no bridge, and Lite has no server to park on. */
export function browserSignInAvailable(): boolean {
return !!getDesktopBridge()?.auth;
return !IS_LITE && !!claimBrowserSignIn() && desktopPolicyValue('BrowserSignIn') === true;
}
/** The Cancel button, and leaving the page. */
export function cancelBrowserSignIn(): void {
if (currentFlow) controls?.cancel(currentFlow);
currentFlow = null;
}
function failure(error: string): DesktopBrowserSignInOutcome {
if (error === 'cancelled') return { kind: 'cancelled' };
if (error === 'denied') return { kind: 'denied' };
if (error === 'timeout') return { kind: 'timeout' };
if (error === 'no_browser') return { kind: 'no_browser' };
if (error === 'policy') return { kind: 'policy' };
if (error === 'declined') return { kind: 'declined' };
if (error === 'invalid') return { kind: 'invalid' };
return { kind: 'failed', error };
}
/** Instance mode. The caller redeems the code (loginWithDesktopHandoff in the auth store). */
export async function runInstanceBrowserSignIn(options: { base: string; locale: string; fresh: boolean }): Promise<DesktopBrowserSignInOutcome> {
const auth = claimBrowserSignIn();
if (!auth) return { kind: 'failed', error: 'unavailable' };
const begun = await auth.begin({ kind: 'instance' });
if (!begun.ok) return failure(begun.error);
currentFlow = begun.flowId;
try {
const verifier = generateCodeVerifier();
const url = new URL(`${options.base}/${options.locale}/login`);
url.searchParams.set('mobile_redirect_uri', begun.redirectUri);
url.searchParams.set('mobile_state', begun.state);
url.searchParams.set('mobile_code_challenge', await generateCodeChallenge(verifier));
if (options.fresh) url.searchParams.set('fresh_login', '1');
const result = await auth.complete(begun.flowId, url.toString());
if (!result.ok) return failure(result.error);
return { kind: 'code', code: result.code, verifier };
} catch (error) {
auth.cancel(begun.flowId);
return { kind: 'failed', error: error instanceof Error ? error.name : 'error' };
} finally {
currentFlow = null;
}
}
/** The page main serves: each post goes through the app's own login, until one works. */
async function runPasswordPage(
auth: DesktopBrowserSignInControls,
flowId: string,
tryPassword: (attempt: Extract<DesktopPasswordSignInAttempt, { ok: true }>) => Promise<DesktopPasswordSignInAnswer>,
): Promise<DesktopBrowserSignInOutcome> {
const started = await auth.beginPassword(flowId);
if (!started.ok) return failure(started.error);
for (;;) {
const attempt = await auth.nextPassword(flowId);
if (!attempt.ok) return attempt.error === 'done' ? { kind: 'signed-in' } : failure(attempt.error);
const answer = await tryPassword(attempt);
auth.answerPassword(flowId, answer);
if (answer.ok) {
const ended = await auth.outcome(flowId);
return ended.ok ? { kind: 'signed-in' } : failure(ended.error);
}
}
}
/** Standalone. Only referenced from behind the desktop build's flag, so no other bundle holds the route. */
export async function runDesktopBrowserSignIn(options: {
serverUrl: string;
callbackPath: string;
slot: number;
remember: boolean;
addAccount: boolean;
fresh: boolean;
/** Redeems the code: loginWithOAuth in the auth store. */
redeem: (serverUrl: string, code: string, verifier: string, redirectUri: string) => Promise<boolean>;
tryPassword: (attempt: Extract<DesktopPasswordSignInAttempt, { ok: true }>) => Promise<DesktopPasswordSignInAnswer>;
}): Promise<DesktopBrowserSignInOutcome> {
const bridge = getDesktopBridge();
if (!bridge?.auth) return { kind: 'unavailable' };
const { serverUrl, callbackPath, slot, remember, addAccount } = options;
const auth = claimBrowserSignIn();
if (!auth) return { kind: 'failed', error: 'unavailable' };
const { serverUrl, slot, remember, addAccount, fresh, redeem, tryPassword } = options;
const prepared = await bridge.servers?.prepare(serverUrl);
if (prepared && !prepared.ok) return { kind: 'declined' };
const { flowId, redirectUri } = await bridge.auth.beginBrowserSignIn();
const begun = await auth.begin({ kind: 'oauth', serverUrl });
if (!begun.ok) return failure(begun.error);
const { flowId } = begun;
currentFlow = flowId;
try {
const res = await apiFetch('/api/auth/desktop-oauth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, redirectUri }),
body: JSON.stringify({ serverUrl, redirectUri: begun.redirectUri }),
});
if (res.status === 404) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
if (body?.error === 'no_oauth') return await runPasswordPage(auth, flowId, tryPassword);
}
if (!res.ok) {
bridge.auth.cancelBrowserSignIn(flowId);
return { kind: 'unavailable' };
auth.cancel(flowId);
return { kind: 'failed', error: 'discovery' };
}
const answer = (await res.json()) as DesktopOAuthAnswer;
let redirectUri = begun.redirectUri;
if (answer.use_scheme) {
const scheme = await auth.useScheme(flowId);
if (!scheme) {
auth.cancel(flowId);
return { kind: 'failed', error: 'scheme' };
}
redirectUri = scheme;
}
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
const authorizeUrl = new URL(answer.authorization_endpoint);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', answer.client_id);
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
authorizeUrl.searchParams.set('scope', answer.scope);
authorizeUrl.searchParams.set('state', state);
authorizeUrl.searchParams.set('code_challenge', challenge);
authorizeUrl.searchParams.set('state', begun.state);
authorizeUrl.searchParams.set('code_challenge', await generateCodeChallenge(verifier));
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
if (addAccount) authorizeUrl.searchParams.set('prompt', 'select_account');
if (fresh) authorizeUrl.searchParams.set('prompt', 'login');
const result = await bridge.auth.completeBrowserSignIn(flowId, authorizeUrl.toString());
if (!result.ok) {
return result.error === 'cancelled' ? { kind: 'cancelled' } : { kind: 'failed', error: result.error };
}
if (result.state !== state) return { kind: 'failed', error: 'state_mismatch' };
const result = await auth.complete(flowId, authorizeUrl.toString());
if (!result.ok) return failure(result.error);
// RFC 9207: the answer names who issued it. Main has already checked the state.
if (result.iss && answer.issuer && result.iss !== answer.issuer) return { kind: 'failed', error: 'issuer_mismatch' };
// What the callback page expects from a same-window flow, plus the two
// values only this flow has.
sessionStorage.setItem('oauth_code_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
sessionStorage.setItem('oauth_server_url', serverUrl);
sessionStorage.removeItem('oauth_server_id');
// What the auth store's exchange reads. The code never goes near an address bar.
sessionStorage.setItem('oauth_remember', String(remember));
sessionStorage.setItem('oauth_cookie_slot', String(slot));
sessionStorage.setItem(DESKTOP_REDIRECT_URI_KEY, redirectUri);
sessionStorage.setItem(DESKTOP_CLIENT_ID_KEY, answer.client_id);
if (addAccount) sessionStorage.setItem('oauth_add_account_mode', 'true');
const query = new URLSearchParams({ code: result.code, state: result.state });
return { kind: 'callback', callbackUrl: `${callbackPath}?${query}` };
try {
return (await redeem(serverUrl, result.code, verifier, redirectUri)) ? { kind: 'signed-in' } : { kind: 'failed', error: 'exchange' };
} finally {
sessionStorage.removeItem(DESKTOP_CLIENT_ID_KEY);
}
} catch (error) {
bridge.auth.cancelBrowserSignIn(flowId);
return { kind: 'failed', error: error instanceof Error ? error.message : String(error) };
auth.cancel(flowId);
return { kind: 'failed', error: error instanceof Error ? error.name : 'error' };
} finally {
currentFlow = null;
}
}
+180
View File
@@ -0,0 +1,180 @@
/**
* "Edit" on an attachment, the page's side. The desktop app copies the file to a private
* folder, opens it in its default program and says when it was saved (the shell's
* features/os-edit.ts). What happens to a saved file is decided here and in what uses
* this: a composer offers "Update attachment" (hooks/use-composer-edits.ts), and the
* rest goes through the offers in components/desktop/edited-files-host.tsx.
*
* The organisation's word on saving attachments and exporting mail is per account and only
* known here, so it's asked at every step, as the local export does around its sink:
* before anything starts, after the bytes arrive, and before a saved file is read back.
* A policy that turns to no ends the session (the host watches), and one that isn't known
* yet counts as no for starting something, but deletes nothing.
*/
import { create } from 'zustand';
import { isAppLocked } from '@/lib/desktop/app-lock-state';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DesktopEditBridge, DesktopEditOpenResult, DesktopEditSession } from '@/lib/desktop/types';
import { canDownload, canExport } from '@/lib/device-policy';
import { scopeDecided } from '@/lib/offline/offline-mode';
import { onDeviceWipe, WipeIncompleteError } from '@/lib/offline/wipe-registry';
import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup';
/** Where a session's file came from, kept by main for as long as the session lives. */
export type EditSource =
/** A message's attachment: offers a reply. */
| { kind: 'mail'; emailId: string; ownerAccountId?: string }
/** A draft's attachment: offers the update, in the composer holding it. */
| { kind: 'draft'; draftId: string | null; ownerAccountId?: string }
/** Its message went or it was taken off a draft: only the file is left. */
| { kind: 'file' };
export function parseEditSource(raw: string): EditSource {
try {
const parsed = JSON.parse(raw) as Partial<EditSource> & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown };
const ownerAccountId = typeof parsed.ownerAccountId === 'string' ? parsed.ownerAccountId : undefined;
if (parsed.kind === 'mail' && typeof parsed.emailId === 'string') return { kind: 'mail', emailId: parsed.emailId, ownerAccountId };
if (parsed.kind === 'draft') return { kind: 'draft', draftId: typeof parsed.draftId === 'string' ? parsed.draftId : null, ownerAccountId };
} catch {
// Falls through: what's left is a file.
}
return { kind: 'file' };
}
export function getEditBridge(): DesktopEditBridge | null {
return getDesktopBridge()?.edit ?? null;
}
/** Nothing in the browser. Otherwise the shell's own list of what may be opened. */
export function canEditFile(name: string | null | undefined): boolean {
return !!name && getEditBridge()?.canEdit(name) === true;
}
/** Whether this account may keep a copy outside the app: saving attachments and export both have to be allowed. */
export function editAllowed(account: string): boolean {
return scopeDecided(account) && canDownload(account) && canExport(account);
}
/** A definite no. An unknown policy isn't one: nothing is deleted for that. */
export function editForbidden(account: string): boolean {
return scopeDecided(account) && !(canDownload(account) && canExport(account));
}
export type EditStartResult = DesktopEditOpenResult | { ok: false; reason: 'policy' | 'unavailable' | 'gesture' };
export interface StartEditInput {
/** One session per key: Edit on the same attachment opens the same file, with its edits. */
key: string;
name: string;
type: string;
account: string;
source: EditSource;
load: () => Promise<Blob | ArrayBuffer>;
}
/** Call it straight from the click: main only starts a session on a real one. */
export async function startAttachmentEdit(input: StartEditInput): Promise<EditStartResult> {
const edit = getEditBridge();
if (!edit) return { ok: false, reason: 'unavailable' };
if (!editAllowed(input.account)) return { ok: false, reason: 'policy' };
const begun = await edit.begin(input.key);
if (!begun.ok) return begun;
if (begun.open) return edit.reopen(begun.ticket, begun.open);
const loaded = await input.load();
// It can take a while to arrive, and the answer can change meanwhile.
if (!editAllowed(input.account)) return { ok: false, reason: 'policy' };
if (isAppLocked()) return { ok: false, reason: 'locked' };
const data = new Uint8Array(loaded instanceof Blob ? await loaded.arrayBuffer() : loaded);
return edit.open(begun.ticket, {
key: input.key,
name: input.name,
type: input.type,
data,
account: input.account,
source: JSON.stringify(input.source),
});
}
/** A file read back remembers which session it came from, so whichever composer it lands in takes the session on. */
const editFiles = new WeakMap<File, { id: string; hash: string }>();
export function editFileInfo(file: File | undefined): { id: string; hash: string } | undefined {
return file ? editFiles.get(file) : undefined;
}
export type EditReadOutcome =
| { ok: true; file: File; hash: string }
| { ok: false; reason: 'policy' | 'locked' | 'missing' | 'busy' | 'too-large' | 'unavailable' };
export async function readEditedFile(session: Pick<DesktopEditSession, 'id' | 'account'>): Promise<EditReadOutcome> {
const edit = getEditBridge();
if (!edit) return { ok: false, reason: 'unavailable' };
if (!editAllowed(session.account)) return { ok: false, reason: 'policy' };
const answer = await edit.read(session.id);
if (!answer.ok) return answer;
const bytes = answer.data;
const file = new File([new Uint8Array(bytes)], answer.name, { type: answer.type || 'application/octet-stream' });
editFiles.set(file, { id: session.id, hash: answer.hash });
return { ok: true, file, hash: answer.hash };
}
const REPLY_WAIT_MS = 2 * 60 * 1000;
const parkedReplies = new Map<string, { files: File[]; at: number }>();
/** The next reply composer opened on this message takes these. Waits here so every way of opening one works. */
export function parkFilesForReply(emailId: string, files: File[]): void {
parkedReplies.set(emailId, { files, at: Date.now() });
}
export function takeFilesForReply(emailId: string | undefined): File[] {
if (!emailId) return [];
const parked = parkedReplies.get(emailId);
parkedReplies.delete(emailId);
return parked && Date.now() - parked.at < REPLY_WAIT_MS ? parked.files : [];
}
interface EditSessionsState {
sessions: DesktopEditSession[];
}
/** What main says, per window. Empty while the app is locked. */
export const useEditSessions = create<EditSessionsState>(() => ({ sessions: [] }));
let listening = false;
/** Once per page, and for its life. Does nothing in a browser. */
export function followEditSessions(): void {
const edit = getEditBridge();
if (!edit || listening) return;
listening = true;
edit.onChange((sessions) => useEditSessions.setState({ sessions }));
void edit.list().then((sessions) => useEditSessions.setState({ sessions }), () => {});
}
/** For tests. */
export function resetEditSessions(): void {
listening = false;
parkedReplies.clear();
useEditSessions.setState({ sessions: [] });
}
const clear = async (account: string | null) => {
await getEditBridge()?.clear(account);
};
const wipe = async (account: string | null) => {
const result = await getEditBridge()?.wipe(account);
if (result && result.held.length > 0) throw new WipeIncompleteError(`${result.held.length} files are held by another program`);
};
// The copies are of mail, so they go with the account. A wipe asks nobody; the question before
// a sign-out the user started is main's, through the host's gate. A sign-out the app decided
// on because it couldn't confirm the login (keepLocalStore) keeps them, as it keeps the store:
// nobody chose it, and these can be someone's only copy of an afternoon's work. While it lasts
// the host doesn't show them, since that account isn't signed in.
onDeviceWipe((account) => wipe(account));
onBeforeSignOut((account, options) => (options.keepLocalStore ? Promise.resolve() : clear(account)));
onFullSignOut((options) => {
if (!options.keepLocalStore) void clear(null);
});
+24
View File
@@ -43,7 +43,31 @@ export function noteComposerLanguage(key: string, language: string | null): void
export function shellLabels(t: (key: string, values?: Record<string, string>) => string): Record<string, string> {
return {
...updateLabels(t),
signInDone: t('desktop_browser_sign_in.page_done'),
signInFailed: t('desktop_browser_sign_in.page_failed'),
signInLinkUsed: t('desktop_browser_sign_in.page_link_used'),
signInDeclined: t('desktop_browser_sign_in.page_declined'),
signInPageTitle: t('desktop_browser_sign_in.page_title', { server: '{server}' }),
signInPageIntro: t('desktop_browser_sign_in.page_intro', { server: '{server}' }),
signInUsername: t('desktop_browser_sign_in.page_username'),
signInPassword: t('desktop_browser_sign_in.page_password'),
signInCode: t('desktop_browser_sign_in.page_code'),
signInSubmit: t('desktop_browser_sign_in.page_submit'),
signInWrongPassword: t('desktop_browser_sign_in.page_wrong_password'),
signInCodeNeeded: t('desktop_browser_sign_in.page_code_needed'),
signInNoAnswer: t('desktop_browser_sign_in.page_no_answer'),
...certificateLabels(t),
editCancel: t('desktopEdit.cancel'),
editDiscardTitle: t('desktopEdit.discard_title', { name: '{name}' }),
editDiscardDetail: t('desktopEdit.discard_detail'),
editDiscardConfirm: t('desktopEdit.discard_confirm'),
editClearTitle: t('desktopEdit.clear_title'),
editClearConfirm: t('desktopEdit.clear_confirm'),
editSignOutTitle: t('desktopEdit.signout_title'),
editSignOutConfirm: t('desktopEdit.signout_confirm'),
editSignOutChanged: t('desktopEdit.signout_changed', { names: '{names}' }),
editSignOutHeld: t('desktopEdit.signout_held', { names: '{names}' }),
editOutsideNote: t('desktopEdit.outside_note'),
menuFile: t('desktopUi.menu.file'),
menuEdit: t('desktopUi.menu.edit'),
menuView: t('desktopUi.menu.view'),
+2
View File
@@ -45,6 +45,8 @@ export interface MailNotificationContext {
accountId?: string;
slot?: number;
login?: string;
/** Not mail: an attachment saved in another program (components/desktop/edited-files-host.tsx). */
editedFile?: boolean;
}
/** True where this module plays the new-mail sound, so useNewMailAlerts doesn't as well. */

Some files were not shown because too many files have changed in this diff Show More