mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Merge branch 'feat/desktop-offline' into feat/do-proxy-fixes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`));
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "I have a 2FA code" 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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('!');
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;";
|
||||
|
||||
@@ -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); }} />
|
||||
)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
<string id="AutoSelectCertificateForUrls_Explain">Eine JSON-Regel pro Zeile, im Format von Chrome: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage.</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>
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
<string id="AutoSelectCertificateForUrls_Explain">One JSON rule per line, in the format Chrome uses: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. The app then picks the matching certificate for that site without asking.</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'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 "Sign in with your browser", so people can use their password manager, passkeys and your single sign-on there. When disabled, the button is gone and the app refuses the flow. The form in the app stays, unless the server only allows OAuth.</string>
|
||||
<string id="WipeOfflineData">Delete mail kept on the device</string>
|
||||
<string id="WipeOfflineData_Explain">Set this to a new value, such as today's date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.</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>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"KeystoreAllowedOrigins": [
|
||||
"https://mail.example.com"
|
||||
],
|
||||
"BrowserSignIn": true,
|
||||
"Recommended": {
|
||||
"UpdateChannel": "stable"
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 --
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -56,6 +56,7 @@ const configValidators = {
|
||||
loginShowSubtitle: isBoolean,
|
||||
loginShowTotp: isBoolean,
|
||||
loginShowTokenLogin: isBoolean,
|
||||
desktopBrowserSignIn: isBoolean,
|
||||
loginShowRememberMe: isBoolean,
|
||||
loginShowVersion: isBoolean,
|
||||
demoMode: isBoolean,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user