Merge branch 'main' into feat/do-packaging

# Conflicts:
#	components/settings/general-settings.tsx
#	lib/desktop/types.ts
This commit is contained in:
Linus Rath
2026-11-03 19:37:41 +01:00
318 changed files with 36199 additions and 724 deletions
+1
View File
@@ -10,6 +10,7 @@ lite-image
!.env.dev.example
scripts/
!scripts/lite/
!scripts/desktop-build-flag.mjs
!scripts/build-plugin-sandbox.mjs
!scripts/build-pst-worker.mjs
!scripts/build-store-worker.mjs
+12
View File
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { AppLockGate } from "@/components/app-lock/app-lock-gate";
import { IntlProvider } from "@/components/providers/intl-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider";
@@ -27,7 +28,9 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { AppUpdatePrompt } from "@/components/app-update-prompt";
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 { DevicePolicyHost } from "@/components/device/device-policy-host";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
@@ -37,6 +40,8 @@ import { FreeBusyAutoShare } from "@/components/calendar/free-busy-auto-share";
import { MainWindowOnly } from "@/components/popout/main-window-only";
import { PopoutHost } from "@/components/popout/popout-host";
import { WindowSyncHost } from "@/components/popout/window-sync-host";
import { DesktopHost } from "@/components/desktop/desktop-host";
import { WindowMenuBar } from "@/components/layout/window-menu-bar";
import { IS_LITE } from "@/lib/lite";
import { setRequestLocale } from "next-intl/server";
import { locales } from "@/i18n/routing";
@@ -75,6 +80,7 @@ export default async function LocaleLayout({
return (
<IntlProvider locale={locale} messages={messages}>
<AppLockGate>
<ThemeProvider>
<CalendarAlertProvider>
<RateLimitToastProvider>
@@ -85,10 +91,13 @@ export default async function LocaleLayout({
<MainWindowOnly>
<ImpersonationReconciler />
<PushBindingsHost />
<DesktopOsHost />
<FreeBusyAutoShare />
{!IS_LITE && <PopoutHost />}
</MainWindowOnly>
{!IS_LITE && <WindowSyncHost />}
{!IS_LITE && <WindowMenuBar />}
{!IS_LITE && <DesktopHost />}
<SessionTimeoutGuard />
<AccountTemplateSync />
{children}
@@ -128,6 +137,9 @@ export default async function LocaleLayout({
</RateLimitToastProvider>
</CalendarAlertProvider>
</ThemeProvider>
</AppLockGate>
{/* Beside the gate, not under it: a stolen device at the lock screen still has to hear a wipe. */}
{!IS_LITE && <DeviceWipeHost />}
</IntlProvider>
);
}
+31 -2
View File
@@ -1,5 +1,6 @@
"use client";
import { freshLoginRequired } from "@/lib/app-lock/sign-out";
import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo } from "react";
import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag";
import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge";
@@ -14,6 +15,8 @@ import { IconButton } from "@/components/ui/icon-button";
import { Input } from "@/components/ui/input";
import { useAuthStore, consumeSignedOut, SESSION_EXPIRED_DRAFTS_UNSAVED_KEY } from "@/stores/auth-store";
import { consumeLost } from "@/lib/offline/lost";
import { consumeWipeNote, onRefusedLoginsChanged, refusedWaits, type RefusedWait, type WipeNote } from "@/lib/devices/wipe-state";
import { wipeNoteText } from "@/components/offline/device-wipe-host";
import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store";
import { usePolicyStore } from "@/stores/policy-store";
@@ -198,6 +201,7 @@ function LoginPageContent() {
const [desktopWaiting, setDesktopWaiting] = useState(false);
const tSessionLimits = useTranslations("session_limits");
const tOffline = useTranslations("offline");
const tOfflineWipe = useTranslations("offlineWipe");
const tTheme = useTranslations("settings.appearance.theme");
const params = useParams();
const searchParams = useSearchParams();
@@ -302,8 +306,16 @@ function LoginPageContent() {
const [sessionEnd, setSessionEnd] = useState<SessionEndReason | null>(null);
// Offline outbox messages a sign-out deleted unsent (#523), named once.
const [outboxLost, setOutboxLost] = useState<string[]>([]);
// Unsent mail a wipe took with it: how many, never which.
const [wipeLost, setWipeLost] = useState<WipeNote | null>(null);
// Unsent mail that waits for a new password: how many and until when, never which.
const [refusedWaiting, setRefusedWaiting] = useState<RefusedWait[]>([]);
useEffect(() => {
setOutboxLost(consumeLost());
setWipeLost(consumeWipeNote());
const read = () => setRefusedWaiting(refusedWaits());
read();
return onRefusedLoginsChanged(read);
}, []);
const [expiredDraftsUnsaved, setExpiredDraftsUnsaved] = useState(false);
const [showPassword, setShowPassword] = useState(false);
@@ -667,6 +679,7 @@ function LoginPageContent() {
locale: params.locale,
server_id: selectedServer?.id,
remember: persistProviderSession,
...(freshLoginRequired() ? { fresh_login: true } : {}),
...(isMobileHandoff
? {
mobile_redirect_uri: mobileRedirectUri,
@@ -920,6 +933,7 @@ function LoginPageContent() {
if (isAddAccountMode) {
authUrl.searchParams.set("prompt", "select_account");
}
if (freshLoginRequired()) authUrl.searchParams.set("prompt", "login");
window.location.href = authUrl.toString();
};
@@ -1018,6 +1032,7 @@ function LoginPageContent() {
if (isAddAccountMode) {
authUrl.searchParams.set("prompt", "select_account");
}
if (freshLoginRequired()) authUrl.searchParams.set("prompt", "login");
window.location.href = authUrl.toString();
};
@@ -1251,7 +1266,7 @@ function LoginPageContent() {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-muted/10 to-muted/30 relative px-4">
{/* Theme toggle, hidden while the theme policy locks the mode (#1280) */}
<div className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
<div data-window-beside-controls="" className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
<button
type="button"
ref={themeButtonRef}
@@ -1417,7 +1432,7 @@ function LoginPageContent() {
}}
/>
{/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}
<div className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
<div data-window-beside-controls="" className="absolute top-5 end-5" ref={themeMenuRef} hidden={colorModeLocked} suppressHydrationWarning>
<button
type="button"
ref={themeButtonRef}
@@ -1498,6 +1513,20 @@ function LoginPageContent() {
{/* Form section */}
<div className="px-8 pb-8">
{wipeLost !== null && (
<div className="mb-5 p-3 rounded-xl border border-warning/30 bg-warning/5 text-sm text-foreground" role="status" data-testid="device-wipe-note">
{wipeNoteText(wipeLost, tOfflineWipe)}
</div>
)}
{refusedWaiting.map((wait) => {
const date = new Date(wait.until).toLocaleDateString(String(params?.locale ?? "en"), { dateStyle: "medium" });
const prefix = wait.kind === "session" ? "session_waiting" : "refused_waiting";
return (
<div key={wait.until} className="mb-5 p-3 rounded-xl border border-warning/30 bg-warning/5 text-sm text-foreground" role="status" data-testid="login-refused-waiting">
{wait.unsent === null ? tOfflineWipe(`${prefix}_unknown`, { date }) : tOfflineWipe(prefix, { count: wait.unsent, date })}
</div>
);
})}
{outboxLost.length > 0 && (
<div className="mb-5 p-3 rounded-xl border border-warning/30 bg-warning/5 text-sm text-foreground [overflow-wrap:anywhere]" role="status" data-testid="session-outbox-lost">
{tOffline("signout.lost", { subjects: outboxLost.map((s) => s || tOffline("outbox.no_subject")).join(", ") })}
@@ -0,0 +1,7 @@
import { PopoutSpareApp } from "@/components/popout/popout-spare-app";
// The desktop app's warm spare (components/popout/popout-spare-app.tsx). Not in
// Lite: scripts/lite removes this tree.
export default function PopoutSparePage() {
return <PopoutSpareApp />;
}
@@ -0,0 +1,93 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { DeviceView } from '@/lib/devices/types';
import { DeviceRegistryPanel } from '../device-registry-panel';
const device = (over: Partial<DeviceView>): DeviceView => ({
id: `${'a'.repeat(64)}/${'b'.repeat(32)}`,
userKey: 'a'.repeat(64),
user: 'alice@example.org',
server: 'https://mail.example.org',
platform: 'Chrome, Windows',
appVersion: '1.2.3',
firstSeen: '2026-11-01T10:00:00.000Z',
lastSeen: '2026-11-03T10:00:00.000Z',
wipe: null,
wipedAt: null,
wipeReason: null,
...over,
});
let rows: DeviceView[];
let posts: Array<Record<string, unknown>>;
beforeEach(() => {
posts = [];
rows = [device({})];
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => {
if (init?.method === 'POST') {
posts.push(JSON.parse(String(init.body)));
return new Response('{}', { status: 200 });
}
expect(String(input)).toContain('/api/admin/devices');
return new Response(JSON.stringify({ devices: rows, total: rows.length }), { status: 200 });
}));
});
describe('the registered devices', () => {
it('lists user, platform, version and last seen', async () => {
render(<DeviceRegistryPanel />);
const row = await screen.findByTestId('device-row');
expect(screen.getByText('alice@example.org')).toBeTruthy();
expect(row.textContent).toContain('Chrome, Windows');
expect(row.textContent).toContain('Version 1.2.3');
expect(row.textContent).toContain('Last seen');
});
it('wipes only after the confirmation, naming who and what', async () => {
render(<DeviceRegistryPanel />);
fireEvent.click(await screen.findByTestId('device-wipe'));
expect(posts).toEqual([]);
const dialog = await screen.findByRole('alertdialog').catch(() => screen.getByRole('dialog'));
expect(dialog.textContent).toContain('alice@example.org');
expect(dialog.textContent).toContain('Mail that was waiting to be sent is lost');
expect(dialog.textContent).toContain('S/MIME and OpenPGP keys imported in that browser or app go for every account there');
fireEvent.click(within(dialog).getByRole('button', { name: 'Wipe' }));
await waitFor(() => expect(posts).toEqual([{ action: 'wipe', id: rows[0].id }]));
});
it('offers all of a user\'s devices once there are several', async () => {
rows = [device({}), device({ id: `${'a'.repeat(64)}/${'c'.repeat(32)}`, platform: 'Desktop app, Windows' })];
render(<DeviceRegistryPanel />);
fireEvent.click(await screen.findByTestId('devices-wipe-user'));
const dialog = await screen.findByRole('alertdialog').catch(() => screen.getByRole('dialog'));
fireEvent.click(within(dialog).getByRole('button', { name: 'Wipe' }));
await waitFor(() => expect(posts).toEqual([{ action: 'wipe-user', userKey: 'a'.repeat(64) }]));
});
it('says a wipe is waiting and lets the admin take it back', async () => {
rows = [device({ wipe: { requestedAt: '2026-11-03T11:00:00.000Z', by: 'admin' } })];
render(<DeviceRegistryPanel />);
const row = await screen.findByTestId('device-row');
expect(row.textContent).toContain('Wipe waiting');
expect(screen.queryByTestId('device-wipe')).toBeNull();
fireEvent.click(screen.getByTestId('device-cancel'));
await waitFor(() => expect(posts).toEqual([{ action: 'cancel', id: rows[0].id }]));
});
it('shows a wiped device as such, with nothing more to wipe', async () => {
rows = [device({ wipedAt: '2026-11-03T12:00:00.000Z', wipeReason: 'user' })];
render(<DeviceRegistryPanel />);
const row = await screen.findByTestId('device-row');
expect(row.textContent).toContain('Wiped');
expect(row.textContent).toContain('by the user');
expect(screen.queryByTestId('device-wipe')).toBeNull();
expect(screen.getByTestId('device-forget')).toBeTruthy();
});
it('says so when nothing registered', async () => {
rows = [];
render(<DeviceRegistryPanel />);
expect(await screen.findByTestId('devices-empty')).toBeTruthy();
});
});
@@ -0,0 +1,251 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Trash2, X } from '@/components/icons';
import { AdminList, AdminListRow } from '@/components/admin/list';
import { Badge } from '@/components/admin/badge';
import { SettingsSection } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { apiFetch } from '@/lib/browser-navigation';
import type { DeviceView } from '@/lib/devices/types';
const PAGE_SIZE = 100;
type Pending =
| { kind: 'device'; device: DeviceView }
| { kind: 'user'; userKey: string; user: string; count: number }
| { kind: 'forget'; device: DeviceView };
function when(iso: string | null): string {
return iso ? new Date(iso).toLocaleString() : '';
}
/**
* Browsers with offline mode on and desktop apps. A wipe waits for the device's
* next contact, and the row says so until the device confirms. Its own section,
* with no draft and no save bar: each action happens when it's confirmed.
*/
export function DeviceRegistryPanel() {
const [devices, setDevices] = useState<DeviceView[] | null>(null);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [query, setQuery] = useState('');
const [search, setSearch] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [pending, setPending] = useState<Pending | null>(null);
const load = useCallback(async () => {
try {
const params = new URLSearchParams({ offset: String(offset), limit: String(PAGE_SIZE) });
if (search) params.set('q', search);
const res = await apiFetch(`/api/admin/devices?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json() as { devices: DeviceView[]; total: number };
setDevices(data.devices);
setTotal(data.total);
setError(null);
} catch (e) {
setError(`Could not load the devices (${(e as Error).message})`);
}
}, [offset, search]);
useEffect(() => { void load(); }, [load]);
const act = async (key: string, body: Record<string, unknown>) => {
setBusy(key);
try {
const res = await apiFetch('/api/admin/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await load();
} catch (e) {
setError(`That didn't work (${(e as Error).message})`);
} finally {
setBusy(null);
}
};
const groups = useMemo(() => {
const byUser = new Map<string, DeviceView[]>();
for (const device of devices ?? []) {
const rows = byUser.get(device.userKey) ?? [];
rows.push(device);
byUser.set(device.userKey, rows);
}
return [...byUser.entries()].map(([userKey, rows]) => ({
userKey,
user: rows[0].user,
server: rows[0].server,
rows: [...rows].sort((a, b) => b.lastSeen.localeCompare(a.lastSeen)),
}));
}, [devices]);
const confirmPending = () => {
if (!pending) return;
setPending(null);
if (pending.kind === 'device') void act(pending.device.id, { action: 'wipe', id: pending.device.id });
else if (pending.kind === 'forget') void act(pending.device.id, { action: 'forget', id: pending.device.id });
else void act(pending.userKey, { action: 'wipe-user', userKey: pending.userKey });
};
return (
<SettingsSection
id="registered"
title="Registered devices"
description="Browsers with offline mode on and desktop apps. A wipe deletes the mail, keys and files kept on the device and signs the account out the next time the device reaches this instance. A device that was signed out or had its browser data cleared has nothing left, and can't confirm: its row keeps saying the wipe is waiting."
>
<form
className="flex flex-wrap items-center gap-2 pb-3"
onSubmit={(event) => { event.preventDefault(); setOffset(0); setSearch(query.trim()); }}
>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by user"
aria-label="Search by user"
className="h-8 min-w-48 flex-1 rounded-md border border-input bg-background px-2 text-sm"
data-testid="devices-search"
/>
<Button type="submit" variant="outline" size="sm" className="h-8 text-xs">Search</Button>
</form>
{error && <p className="pb-3 text-sm text-destructive" role="alert">{error}</p>}
{devices !== null && devices.length === 0 && (
<p className="py-3 text-sm text-muted-foreground" data-testid="devices-empty">
{search ? 'No device matches that search.' : 'No device has registered yet.'}
</p>
)}
<div className="space-y-4">
{groups.map((group) => {
const wipeable = group.rows.filter((device) => !device.wipe && !device.wipedAt);
return (
<div key={group.userKey} data-testid="devices-user" data-user={group.user}>
<div className="flex flex-wrap items-center justify-between gap-2 pb-2">
<p className="min-w-0 text-sm font-medium text-foreground [overflow-wrap:anywhere]">
{group.user}
<span className="ms-2 text-xs font-normal text-muted-foreground">{group.server}</span>
</p>
{wipeable.length > 1 && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
disabled={busy === group.userKey}
onClick={() => setPending({ kind: 'user', userKey: group.userKey, user: group.user, count: wipeable.length })}
data-testid="devices-wipe-user"
>
<Trash2 className="h-3.5 w-3.5" /> Wipe all {wipeable.length} devices
</Button>
)}
</div>
<AdminList>
{group.rows.map((device) => (
<AdminListRow
key={device.id}
data-testid="device-row"
title={device.platform || 'Unknown device'}
titleExtra={device.wipe
? <Badge tone="warning">Wipe waiting</Badge>
: device.wipedAt
? <Badge tone="muted">Wiped</Badge>
: null}
description={device.wipe
? `Asked ${when(device.wipe.requestedAt)}. Happens the next time this device connects.`
: device.wipedAt
? `Wiped ${when(device.wipedAt)}${device.wipeReason && device.wipeReason !== 'admin' ? ` (${wipeReasonLabel(device.wipeReason)})` : ''}`
: undefined}
meta={`${device.appVersion ? `Version ${device.appVersion} · ` : ''}Last seen ${when(device.lastSeen)} · First seen ${when(device.firstSeen)}`}
control={(
<div className="flex flex-wrap items-center justify-end gap-2">
{!device.wipe && !device.wipedAt && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
disabled={busy === device.id}
onClick={() => setPending({ kind: 'device', device })}
data-testid="device-wipe"
>
<Trash2 className="h-3.5 w-3.5" /> Wipe
</Button>
)}
{device.wipe && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
disabled={busy === device.id}
onClick={() => { void act(device.id, { action: 'cancel', id: device.id }); }}
data-testid="device-cancel"
>
<X className="h-3.5 w-3.5" /> Take back
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 text-xs text-muted-foreground"
disabled={busy === device.id}
onClick={() => setPending({ kind: 'forget', device })}
data-testid="device-forget"
>
Forget
</Button>
</div>
)}
/>
))}
</AdminList>
</div>
);
})}
</div>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between gap-2 pt-3 text-xs text-muted-foreground">
<span>{offset + 1} to {Math.min(offset + PAGE_SIZE, total)} of {total}</span>
<span className="flex gap-2">
<Button type="button" variant="outline" size="sm" className="h-8 text-xs" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>Previous</Button>
<Button type="button" variant="outline" size="sm" className="h-8 text-xs" disabled={offset + PAGE_SIZE >= total} onClick={() => setOffset(offset + PAGE_SIZE)}>Next</Button>
</span>
</div>
)}
<ConfirmDialog
isOpen={pending !== null}
onClose={() => setPending(null)}
onConfirm={confirmPending}
title={pending?.kind === 'forget' ? 'Forget this device?' : 'Wipe this data?'}
message={confirmMessage(pending)}
confirmText={pending?.kind === 'forget' ? 'Forget' : 'Wipe'}
variant="destructive"
/>
</SettingsSection>
);
}
function wipeReasonLabel(reason: NonNullable<DeviceView['wipeReason']>): string {
switch (reason) {
case 'user': return 'by the user';
case 'rejected': return 'the server refused the login';
case 'session': return 'the session limit ran out';
default: return reason;
}
}
function confirmMessage(pending: Pending | null): string {
if (!pending) return '';
const consequences = 'Mail, keys and files kept on it are deleted and the account is signed out the next time it connects. Mail that was waiting to be sent is lost. S/MIME and OpenPGP keys imported in that browser or app go for every account there, because they are not kept per account: anyone using them has to import them again. Keys in the OS certificate store stay. The mail on the server is not touched.';
if (pending.kind === 'device') return `${pending.device.platform || 'This device'} of ${pending.device.user}. ${consequences}`;
if (pending.kind === 'user') return `All ${pending.count} devices of ${pending.user}. ${consequences}`;
return pending.device.wipe
? `The wipe waiting for ${pending.device.platform || 'this device'} of ${pending.device.user} is dropped with its row. The device will not be told.`
: `${pending.device.platform || 'This device'} of ${pending.device.user} leaves this list. It comes back as a new row if it registers again.`;
}
+4
View File
@@ -63,6 +63,10 @@ export function LogsPage() {
<option value="config.update">Config Update</option>
<option value="config.revert">Config Revert</option>
<option value="policy.update">Policy Update</option>
<option value="device.wipe.request">Device Wipe Requested</option>
<option value="device.wipe.confirm">Device Wipe Confirmed</option>
<option value="device.wipe.cancel">Device Wipe Cancelled</option>
<option value="device.forget">Device Forgotten</option>
</select>
<Button type="button" variant="outline" size="sm" className="gap-2" onClick={fetchLogs} disabled={loading}>
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin [animation-direction:reverse]')} />
+2
View File
@@ -4,6 +4,7 @@ import { OrgPage } from '@/components/admin/org/org-page';
import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
import { DevicesSection } from '@/components/admin/org/sections/devices-section';
import { OfflineSection } from '@/components/admin/org/sections/offline-section';
import { DeviceRegistryPanel } from './device-registry-panel';
export function OrgDevicesPage() {
const draft = useOrgPolicyDraft('org-devices');
@@ -11,6 +12,7 @@ export function OrgDevicesPage() {
<OrgPage pageId="org-devices" draft={draft}>
<DevicesSection draft={draft} />
<OfflineSection draft={draft} />
<DeviceRegistryPanel />
</OrgPage>
);
}
+2
View File
@@ -4,6 +4,7 @@ import { ThemeProvider } from '@/components/providers/theme-provider';
import { TourProvider } from '@/components/tour/tour-provider';
import { defaultLocale } from '@/i18n/routing';
import { AdminLayoutClient } from './_layout-client';
import { WindowMenuBar } from '@/components/layout/window-menu-bar';
/**
* /admin sits outside the [locale] tree, so it loads the message catalog
@@ -25,6 +26,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<IntlProvider locale={locale} messages={messages}>
<ThemeProvider>
<TourProvider>
<WindowMenuBar />
<AdminLayoutClient>{children}</AdminLayoutClient>
</TourProvider>
</ThemeProvider>
+5 -2
View File
@@ -20,11 +20,13 @@ import { locales, defaultLocale } from "@/i18n/routing";
import { IS_LITE, LITE_MOUNT_GLOBAL } from "@/lib/lite";
import { accessibilityBootScript } from "@/lib/accessibility-settings";
import { bootSkeletonScript } from "@/lib/boot-skeleton/boot-script";
import { WindowTitleBar } from "@/components/layout/window-title-bar";
import { WindowControlsHost } from "@/components/layout/window-controls-host";
import "../globals.css";
import "../print.css";
import "../desktop.css";
import { desktopBootScript } from "@/lib/desktop/boot";
import { appLockBootScript } from "@/lib/app-lock/state";
import "../app-lock.css";
// This layout renders <html> and sits ABOVE the [locale] segment, so
// next-intl's getLocale() returns the default locale here - emitting
@@ -312,6 +314,7 @@ export default async function RootLayout({
__html: `
(function() {${IS_LITE ? liteLocaleBootstrap() : ""}
${desktopBootScript()}
${IS_LITE ? "" : appLockBootScript()}
try {
const stored = localStorage.getItem('theme-storage');
const theme = stored ? JSON.parse(stored).state.theme : 'system';
@@ -334,7 +337,7 @@ export default async function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} ${hankenGrotesk.variable} antialiased`}
>
<ServiceWorkerRegistration />
<WindowTitleBar />
<WindowControlsHost />
{!themeColorConfigured && <ThemeColorSync />}
<FaviconBadge />
<ErrorReportingRoot />
@@ -0,0 +1,91 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { NextRequest } from 'next/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The real admin check, not a stand-in: a wipe request from another site carries
// the admin's cookie, and only the same-origin check stands in its way.
const jar = vi.hoisted(() => new Map<string, string>());
vi.mock('next/headers', () => ({
cookies: async () => ({
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name) } : undefined),
set: (name: string, value: string) => { jar.set(name, value); },
delete: (name: string) => { jar.delete(name); },
}),
}));
let dir: string;
function post(body: unknown, headers: Record<string, string>): NextRequest {
return new NextRequest('https://mail.example.org/api/admin/devices', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json', ...headers },
});
}
function audit(): string {
try {
return readFileSync(path.join(dir, 'audit.log'), 'utf8');
} catch {
return '';
}
}
describe('/api/admin/devices with the real admin check', () => {
let route: typeof import('../route');
let registry: typeof import('@/lib/devices/registry');
let row: { id: string };
beforeEach(async () => {
vi.resetModules();
dir = mkdtempSync(path.join(tmpdir(), 'bw-device-admin-'));
vi.stubEnv('ADMIN_STATE_DIR', dir);
vi.stubEnv('ADMIN_CONFIG_DIR', dir);
vi.stubEnv('SESSION_SECRET', 'x'.repeat(64));
jar.clear();
route = await import('../route');
registry = await import('@/lib/devices/registry');
row = await registry.registerDevice('alice@example.org', 'https://mail.example.org', { platform: 'x', appVersion: '1' });
const session = await import('@/lib/admin/session');
const { getAdminSessionGeneration } = await import('@/lib/admin/password');
jar.set('admin_session', session.createAdminSession(await getAdminSessionGeneration()));
});
afterEach(() => {
vi.unstubAllEnvs();
rmSync(dir, { recursive: true, force: true });
});
it.each([
['another site says so', { 'sec-fetch-site': 'cross-site' }],
['a page on another origin', { origin: 'https://evil.example.net' }],
])('refuses a wipe with the admin\'s own cookie when %s', async (_name, headers) => {
const response = await route.POST(post({ action: 'wipe', id: row.id }, headers));
expect(response.status).toBe(403);
expect((await registry.getDevice(row.id))?.wipe).toBeNull();
expect(audit()).toBe('');
});
it('refuses wiping a whole user from another site', async () => {
const response = await route.POST(post({ action: 'wipe-user', userKey: row.id.split('/')[0] }, { 'sec-fetch-site': 'cross-site' }));
expect(response.status).toBe(403);
expect((await registry.getDevice(row.id))?.wipe).toBeNull();
});
it('takes it from the same origin with the cookie', async () => {
const response = await route.POST(post({ action: 'wipe', id: row.id }, { 'sec-fetch-site': 'same-origin' }));
expect(response.status).toBe(200);
expect((await registry.getDevice(row.id))?.wipe).toMatchObject({ by: 'admin' });
expect(audit()).toContain('device.wipe.request');
});
it('refuses without the cookie, same origin or not', async () => {
jar.clear();
const response = await route.POST(post({ action: 'wipe', id: row.id }, { 'sec-fetch-site': 'same-origin' }));
expect(response.status).toBe(401);
expect((await registry.getDevice(row.id))?.wipe).toBeNull();
});
});
+114
View File
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditLog } from '@/lib/admin/audit';
import { getClientIP, requireAdminAuth } from '@/lib/admin/session';
import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response';
import {
cancelDeviceWipe,
DEVICE_ID_RE,
forgetDevice,
getDevice,
isUserKey,
listDevices,
requestDeviceWipe,
requestUserWipe,
} from '@/lib/devices/registry';
import { AUDIT_DEVICE_FORGET, AUDIT_DEVICE_WIPE_CANCEL, AUDIT_DEVICE_WIPE_REQUEST, type DeviceView } from '@/lib/devices/types';
import { logger } from '@/lib/logger';
const MAX_PAGE = 200;
function positiveInt(raw: string | null, fallback: number): number {
const parsed = Number.parseInt(raw ?? '', 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}
function auditDetail(device: DeviceView, by: string): Record<string, unknown> {
return { device: device.id, user: device.user, server: device.server, platform: device.platform, by };
}
/** Nothing to do: the row as it stands, so a stale page catches up. Only a missing row is an error. */
async function unchanged(id: string): Promise<NextResponse> {
const device = await getDevice(id);
return device ? NextResponse.json({ device }) : NextResponse.json({ error: 'not found' }, { status: 404 });
}
/** GET /api/admin/devices?offset=&limit=&q= : the registered devices, a page at a time. */
export async function GET(request: NextRequest) {
try {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const { searchParams } = request.nextUrl;
const page = await listDevices({
offset: positiveInt(searchParams.get('offset'), 0),
limit: Math.min(positiveInt(searchParams.get('limit'), 100) || 100, MAX_PAGE),
query: searchParams.get('q') ?? undefined,
});
return NextResponse.json(page, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
const unavailable = storeUnavailableResponse(error);
if (unavailable) return unavailable;
logger.error('admin devices GET', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/admin/devices
* { action: 'wipe' | 'cancel' | 'forget', id }
* { action: 'wipe-user', userKey }
*
* A wipe waits until the device next reaches this instance. Each request and
* each confirmed wipe goes in the audit log. Someone who controls the instance
* can wipe any device that checks in here: that's the admin's power, on the record.
*/
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
// The admin session has a role and no name.
const by = 'admin';
const ip = getClientIP(request);
const body = await request.json().catch(() => null) as { action?: unknown; id?: unknown; userKey?: unknown } | null;
const action = body?.action;
if (action === 'wipe-user') {
if (!isUserKey(body?.userKey)) return NextResponse.json({ error: 'invalid userKey' }, { status: 400 });
const devices = await requestUserWipe(body.userKey, by);
for (const device of devices) {
if (device.wipe) await auditLog(AUDIT_DEVICE_WIPE_REQUEST, auditDetail(device, by), ip);
}
return NextResponse.json({ devices });
}
if (typeof body?.id !== 'string' || !DEVICE_ID_RE.test(body.id)) {
return NextResponse.json({ error: 'invalid id' }, { status: 400 });
}
if (action === 'wipe') {
const device = await requestDeviceWipe(body.id, by);
if (!device) return unchanged(body.id);
await auditLog(AUDIT_DEVICE_WIPE_REQUEST, auditDetail(device, by), ip);
return NextResponse.json({ device });
}
if (action === 'cancel') {
const device = await cancelDeviceWipe(body.id);
if (!device) return unchanged(body.id);
await auditLog(AUDIT_DEVICE_WIPE_CANCEL, auditDetail(device, by), ip);
return NextResponse.json({ device });
}
if (action === 'forget') {
const device = await getDevice(body.id);
if (!device) return NextResponse.json({ error: 'not found' }, { status: 404 });
await forgetDevice(body.id);
await auditLog(AUDIT_DEVICE_FORGET, auditDetail(device, by), ip);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
} catch (error) {
const unavailable = storeUnavailableResponse(error);
if (unavailable) return unavailable;
logger.error('admin devices POST', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+4 -1
View File
@@ -55,6 +55,7 @@ export async function POST(request: NextRequest) {
purpose: rawPurpose,
slot: rawSlot,
remember,
fresh_login: freshLogin,
} = await request.json();
// `reauth` drives the step-up flow for device pairing: it forces a fresh
@@ -178,7 +179,9 @@ export async function POST(request: NextRequest) {
// depends on the IdP supporting this OIDC param. Deliberately no max_age=0:
// some IdPs (Authelia) re-evaluate max_age at the consent step and loop
// back to login forever, and nothing on our side reads auth_time.
if (isReauth) {
// The same after a forgotten app lock PIN: the provider's session outlived it, and
// someone at the device shouldn't walk back in through it (lib/app-lock/sign-out.ts).
if (isReauth || freshLogin === true) {
authUrl.searchParams.set('prompt', 'login');
}
+320
View File
@@ -0,0 +1,320 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { NextRequest } from 'next/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const creds = vi.hoisted(() => ({ value: null as null | { accountName: string; serverUrl: string } }));
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => creds.value }));
const admin = vi.hoisted(() => ({ allowed: true }));
// Rate limits are per address and outlive a test: each test is its own address.
const client = vi.hoisted(() => ({ ip: '', count: 0 }));
vi.mock('@/lib/admin/session', async (original) => ({
...await original<typeof import('@/lib/admin/session')>(),
requireAdminAuth: async () => (admin.allowed
? { session: { role: 'admin' } }
: { error: new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) }),
getClientIP: () => client.ip,
}));
import { POST as checkIn } from '../route';
import { POST as wiped } from '../wiped/route';
import { GET as adminList, POST as adminAct } from '../../admin/devices/route';
import * as registry from '@/lib/devices/registry';
let dir: string;
function request(url: string, body: unknown, headers: Record<string, string> = {}): NextRequest {
return new NextRequest(`https://mail.example.org${url}`, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json', 'sec-fetch-site': 'same-origin', ...headers },
});
}
function audit(): Array<{ action: string; detail: Record<string, unknown>; ip: string }> {
try {
return readFileSync(path.join(dir, 'audit.log'), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
} catch {
return [];
}
}
async function register(): Promise<{ id: string; secret: string }> {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const response = await checkIn(request('/api/devices', { platform: 'Chrome, Windows', appVersion: '1.0.0' }, { 'X-JMAP-Cookie-Slot': '0' }));
expect(response.status).toBe(200);
const body = await response.json() as { id: string; secret: string };
// Everything after this works with no session at all.
creds.value = null;
return body;
}
vi.setConfig({ testTimeout: 60_000 });
beforeEach(() => {
client.count += 1;
client.ip = `203.0.113.${client.count}`;
dir = mkdtempSync(path.join(tmpdir(), 'bw-device-routes-'));
vi.stubEnv('ADMIN_STATE_DIR', dir);
admin.allowed = true;
creds.value = null;
});
afterEach(() => {
vi.unstubAllEnvs();
rmSync(dir, { recursive: true, force: true });
});
describe('POST /api/devices', () => {
it('won\'t register anyone without a session cookie', async () => {
const response = await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }));
expect(response.status).toBe(401);
expect((await registry.listDevices()).total).toBe(0);
});
it('keys a new row on the cookie\'s account name', async () => {
const { id } = await register();
expect(id.split('/')[0]).toBe((await registry.listDevices()).devices[0].userKey);
expect((await registry.getDevice(id))?.user).toBe('alice@example.org');
});
it('refuses another site', async () => {
const response = await checkIn(request('/api/devices', { id: 'x', secret: 'y' }, { 'sec-fetch-site': 'cross-site' }));
expect(response.status).toBe(403);
});
it('tells a device with no cookies about its wipe, going by its secret', async () => {
const { id, secret } = await register();
const before = await checkIn(request('/api/devices', { id, secret, platform: 'x', appVersion: '1' }));
expect(await before.json()).toEqual({ wipe: null });
await registry.requestDeviceWipe(id, 'admin');
const after = await checkIn(request('/api/devices', { id, secret, platform: 'x', appVersion: '1' }));
expect((await after.json() as { wipe: unknown }).wipe).toMatchObject({ requestedAt: expect.any(String) });
});
it('says the same for a wrong secret as for a row that isn\'t there', async () => {
const { id } = await register();
const wrong = await checkIn(request('/api/devices', { id, secret: 'f'.repeat(64) }));
const missing = await checkIn(request('/api/devices', { id: `${'a'.repeat(64)}/${'b'.repeat(32)}`, secret: 'f'.repeat(64) }));
expect(wrong.status).toBe(404);
expect(await wrong.json()).toEqual(await missing.json());
});
it('tells the device whose row was wiped, so it can start over', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
await registry.confirmDeviceWipe(id, secret, 'admin');
const answer = await checkIn(request('/api/devices', { id, secret, platform: 'x', appVersion: '1' }));
expect(await answer.json()).toEqual({ wipe: null, wiped: true });
});
it('gives a new row\'s owner with the row, so the device can check it', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const response = await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }));
expect(await response.json()).toMatchObject({ user: 'alice@example.org', server: 'https://mail.example.org' });
});
it('holds new rows to a much lower limit per address than check-ins, and a 429 changes nothing', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const statuses: number[] = [];
for (let i = 0; i <= registry.REGISTER_PER_HOUR; i += 1) {
statuses.push((await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }))).status);
}
expect(statuses.slice(0, registry.REGISTER_PER_HOUR).every((status) => status === 200)).toBe(true);
expect(statuses.at(-1)).toBe(429);
expect((await registry.listDevices()).total).toBe(registry.REGISTER_PER_HOUR);
// A device that's already registered still hears about its wipe.
const [{ id }] = (await registry.listDevices()).devices;
await registry.requestDeviceWipe(id, 'admin');
const answer = await checkIn(request('/api/devices', { id, secret: 'f'.repeat(64) }));
expect(answer.status).toBe(404);
});
it('turns down a new row past the cap when every row has a wipe waiting', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const spy = vi.spyOn(registry, 'registerDevice').mockRejectedValueOnce(new registry.TooManyDevicesError());
const response = await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }));
spy.mockRestore();
expect(response.status).toBe(429);
});
it('won\'t store a platform with control characters or at any length', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const response = await checkIn(request('/api/devices', { platform: `a\nb${'x'.repeat(200)}`, appVersion: '1' }));
const { id } = await response.json() as { id: string };
const device = await registry.getDevice(id);
expect(device?.platform).not.toContain('\n');
expect(device?.platform.length).toBeLessThanOrEqual(64);
});
});
describe('POST /api/devices/wiped', () => {
it('confirms with the secret alone and writes an audit entry', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
const response = await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: 2 }));
expect(response.status).toBe(200);
const device = await registry.getDevice(id);
expect(device?.wipe).toBeNull();
expect(device?.wipedAt).not.toBeNull();
const entry = audit().find((line) => line.action === 'device.wipe.confirm');
expect(entry?.detail).toMatchObject({ device: id, user: 'alice@example.org', reason: 'admin', unsentDeleted: 2 });
expect(entry?.ip).toBe(client.ip);
});
it('sent again, writes no second entry: a row that didn\'t change leaves no trace', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
for (let i = 0; i < 3; i += 1) {
expect((await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: 2 }))).status).toBe(200);
}
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toHaveLength(1);
});
it('won\'t let any signed-in user fill the log: repeating a user wipe writes one entry', async () => {
const { id, secret } = await register();
for (let i = 0; i < 3; i += 1) await wiped(request('/api/devices/wiped', { id, secret, reason: 'user', unsent: 0 }));
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toHaveLength(1);
});
it('refuses reason admin when no wipe was asked, changes nothing and writes nothing', async () => {
const { id, secret } = await register();
const response = await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: 0 }));
expect(response.status).toBe(409);
expect(await response.json()).toEqual({ error: 'no_wipe_asked' });
expect((await registry.getDevice(id))?.wipedAt).toBeNull();
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toEqual([]);
});
it('turns down a reason it doesn\'t know instead of calling it the admin\'s', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
expect((await wiped(request('/api/devices/wiped', { id, secret, reason: 'mischief' }))).status).toBe(400);
expect((await registry.getDevice(id))?.wipe).not.toBeNull();
});
it('records an unknown count as unknown, not as none', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: null }));
expect(audit().find((line) => line.action === 'device.wipe.confirm')?.detail).toMatchObject({ unsentDeleted: 'unknown' });
});
it('a sign-out drops the row with its secret, and writes nothing', async () => {
const { id, secret } = await register();
expect((await wiped(request('/api/devices/wiped', { id, secret, drop: true }))).status).toBe(200);
expect(await registry.getDevice(id)).toBeNull();
expect(audit()).toEqual([]);
});
it('a sign-out with a wipe waiting answers that wipe: the row stays, confirmed and on the record', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
expect((await wiped(request('/api/devices/wiped', { id, secret, drop: true }))).status).toBe(200);
const device = await registry.getDevice(id);
expect(device?.wipe).toBeNull();
expect(device?.wipedAt).not.toBeNull();
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toHaveLength(1);
});
it('won\'t drop a row without the right secret', async () => {
const { id } = await register();
expect((await wiped(request('/api/devices/wiped', { id, secret: 'f'.repeat(64), drop: true }))).status).toBe(404);
expect(await registry.getDevice(id)).not.toBeNull();
});
it('answers 429 past the limit and takes nothing', async () => {
const { id } = await register();
await registry.requestDeviceWipe(id, 'admin');
let last = 200;
for (let i = 0; i <= 300 && last !== 429; i += 1) {
last = (await wiped(request('/api/devices/wiped', { id, secret: 'f'.repeat(64), reason: 'admin' }))).status;
}
expect(last).toBe(429);
expect((await registry.getDevice(id))?.wipe).not.toBeNull();
});
it('takes nothing without the right secret and writes no entry', async () => {
const { id } = await register();
await registry.requestDeviceWipe(id, 'admin');
const response = await wiped(request('/api/devices/wiped', { id, secret: 'f'.repeat(64), reason: 'admin' }));
expect(response.status).toBe(404);
expect((await registry.getDevice(id))?.wipe).not.toBeNull();
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toEqual([]);
});
});
describe('/api/admin/devices', () => {
it('is closed without an admin session', async () => {
admin.allowed = false;
expect((await adminAct(request('/api/admin/devices', { action: 'wipe', id: 'x' }))).status).toBe(401);
expect((await adminList(new NextRequest('https://mail.example.org/api/admin/devices'))).status).toBe(401);
});
it('wipes one device and writes an audit entry for the request', async () => {
const { id } = await register();
const response = await adminAct(request('/api/admin/devices', { action: 'wipe', id }));
expect(response.status).toBe(200);
expect((await registry.getDevice(id))?.wipe).toMatchObject({ by: 'admin' });
expect(audit().map((line) => line.action)).toEqual(['device.wipe.request']);
expect(audit()[0].detail).toMatchObject({ device: id, user: 'alice@example.org', by: 'admin' });
});
it('asking again writes no second entry', async () => {
const { id } = await register();
await adminAct(request('/api/admin/devices', { action: 'wipe', id }));
const again = await adminAct(request('/api/admin/devices', { action: 'wipe', id }));
expect(again.status).toBe(200);
expect(audit().filter((line) => line.action === 'device.wipe.request')).toHaveLength(1);
});
it('wipes all of a user\'s devices, one audit entry each', async () => {
const first = await register();
const second = await register();
const response = await adminAct(request('/api/admin/devices', { action: 'wipe-user', userKey: first.id.split('/')[0] }));
expect(response.status).toBe(200);
expect((await registry.getDevice(first.id))?.wipe).not.toBeNull();
expect((await registry.getDevice(second.id))?.wipe).not.toBeNull();
expect(audit().filter((line) => line.action === 'device.wipe.request')).toHaveLength(2);
});
it('takes a wipe back and forgets a row, on the record', async () => {
const { id } = await register();
await adminAct(request('/api/admin/devices', { action: 'wipe', id }));
await adminAct(request('/api/admin/devices', { action: 'cancel', id }));
expect((await registry.getDevice(id))?.wipe).toBeNull();
await adminAct(request('/api/admin/devices', { action: 'forget', id }));
expect(await registry.getDevice(id)).toBeNull();
expect(audit().map((line) => line.action)).toEqual(['device.wipe.request', 'device.wipe.cancel', 'device.forget']);
});
it('lists without any secret', async () => {
const { secret } = await register();
const response = await adminList(new NextRequest('https://mail.example.org/api/admin/devices'));
const text = await response.text();
expect(JSON.parse(text).total).toBe(1);
expect(text).not.toContain(secret);
expect(text).not.toContain('secretHash');
});
it('turns down nonsense', async () => {
expect((await adminAct(request('/api/admin/devices', { action: 'wipe', id: '../etc/passwd' }))).status).toBe(400);
expect((await adminAct(request('/api/admin/devices', { action: 'wipe-user', userKey: 'nope' }))).status).toBe(400);
expect((await adminAct(request('/api/admin/devices', { action: 'wipe', id: `${'a'.repeat(64)}/${'b'.repeat(32)}` }))).status).toBe(404);
});
});
describe('the audit log page', () => {
it('can filter by every device action', () => {
const page = readFileSync(path.join(process.cwd(), 'app/(main)/admin/_pages/logs.tsx'), 'utf8');
for (const action of ['device.wipe.request', 'device.wipe.confirm', 'device.wipe.cancel', 'device.forget']) {
expect(page).toContain(`value="${action}"`);
}
});
});
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server';
import { getClientIP } from '@/lib/admin/session';
import { checkInDevice, DEVICE_ID_RE, DEVICE_SECRET_RE, REGISTER_PER_HOUR, registerDevice, TooManyDevicesError } from '@/lib/devices/registry';
import { logger } from '@/lib/logger';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStore } from '@/lib/shared-store';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { isStoreUnavailable } from '@/lib/stored-data-errors';
const NO_STORE = { 'Cache-Control': 'no-store' };
const MAX_BODY_BYTES = 2048;
// Per address: a whole office can sit behind one, checking in as it starts up.
const PER_MINUTE = 300;
function cleanText(value: unknown): string {
if (typeof value !== 'string') return '';
return value.replace(/[^\x20-\x7e]/g, '').trim().slice(0, 64);
}
/**
* POST /api/devices
*
* With `{ id, secret }`: a device checks in and hears whether a wipe waits for
* it. No session needed, and nothing goes to the mail server: a device whose
* password was revoked still has to learn about its wipe.
*
* Without them: a new device registers for the signed-in account in the sealed
* context cookie (the slot header picks which). That needs a live session
* cookie, so a browser that restarted registers once its session is restored.
*/
export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const limit = await getStore().counters.reserve([
{ key: `device-checkin:${getClientIP(request)}`, max: PER_MINUTE, windowMs: 60_000 },
]).catch(() => ({ ok: true }));
if (!limit.ok) return NextResponse.json({ error: 'rate limited' }, { status: 429, headers: NO_STORE });
try {
const text = await request.text();
if (text.length > MAX_BODY_BYTES) return NextResponse.json({ error: 'too large' }, { status: 413, headers: NO_STORE });
let body: Record<string, unknown>;
try {
body = JSON.parse(text) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
}
const info = { platform: cleanText(body?.platform), appVersion: cleanText(body?.appVersion) };
if (body?.id !== undefined || body?.secret !== undefined) {
if (typeof body.id !== 'string' || !DEVICE_ID_RE.test(body.id) || typeof body.secret !== 'string' || !DEVICE_SECRET_RE.test(body.secret)) {
return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
}
const device = await checkInDevice(body.id, body.secret, info);
if (!device) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
if (device.wipe) return NextResponse.json({ wipe: { requestedAt: device.wipe.requestedAt } }, { headers: NO_STORE });
// Its data went already. A device still holding this row starts over.
return NextResponse.json(device.wipedAt ? { wipe: null, wiped: true } : { wipe: null }, { headers: NO_STORE });
}
const creds = await getStalwartCredentials(request);
if (!creds) return NextResponse.json({ error: 'Not authenticated' }, { status: 401, headers: NO_STORE });
const newRows = await getStore().counters.reserve([
{ key: `device-register:${getClientIP(request)}`, max: REGISTER_PER_HOUR, windowMs: 60 * 60 * 1000 },
]).catch(() => ({ ok: true }));
if (!newRows.ok) return NextResponse.json({ error: 'rate limited' }, { status: 429, headers: NO_STORE });
const created = await registerDevice(creds.accountName, creds.serverUrl, info);
return NextResponse.json({ ...created, wipe: null }, { headers: NO_STORE });
} catch (error) {
if (error instanceof Error && error.name === new TooManyDevicesError().name) {
return NextResponse.json({ error: 'too_many_devices' }, { status: 429, headers: NO_STORE });
}
if (isStoreUnavailable(error)) {
return NextResponse.json({ error: 'Temporarily unavailable' }, { status: 503, headers: { ...NO_STORE, 'Retry-After': '30' } });
}
logger.error('device check-in', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
}
}
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditLog } from '@/lib/admin/audit';
import { getClientIP } from '@/lib/admin/session';
import { confirmDeviceWipe, DEVICE_ID_RE, DEVICE_SECRET_RE, dropDevice } from '@/lib/devices/registry';
import type { DeviceView } from '@/lib/devices/types';
import { AUDIT_DEVICE_WIPE_CONFIRM, WIPE_REASONS, type WipeReason } from '@/lib/devices/types';
import { logger } from '@/lib/logger';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStore } from '@/lib/shared-store';
import { isStoreUnavailable } from '@/lib/stored-data-errors';
const NO_STORE = { 'Cache-Control': 'no-store' };
const MAX_BODY_BYTES = 1024;
// Per address: a whole office can sit behind one, checking in as it starts up.
const PER_MINUTE = 300;
/**
* POST /api/devices/wiped: a device says its wipe is done. By then it has signed
* out or is about to, so its secret is the proof, not a session. Sent again at
* the next contact until it gets through, so it answers the same each time, and
* only a row that really changes goes in the log.
*
* With `drop`: the account signed out here, with its data, so the row goes too.
*/
export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const limit = await getStore().counters.reserve([
{ key: `device-checkin:${getClientIP(request)}`, max: PER_MINUTE, windowMs: 60_000 },
]).catch(() => ({ ok: true }));
if (!limit.ok) return NextResponse.json({ error: 'rate limited' }, { status: 429, headers: NO_STORE });
try {
const text = await request.text();
if (text.length > MAX_BODY_BYTES) return NextResponse.json({ error: 'too large' }, { status: 413, headers: NO_STORE });
let body: Record<string, unknown>;
try {
body = JSON.parse(text) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
}
if (typeof body?.id !== 'string' || !DEVICE_ID_RE.test(body.id) || typeof body.secret !== 'string' || !DEVICE_SECRET_RE.test(body.secret)) {
return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
}
const write = (device: DeviceView, reason: WipeReason, unsentDeleted: number | 'unknown') => auditLog(AUDIT_DEVICE_WIPE_CONFIRM, {
device: device.id,
user: device.user,
server: device.server,
platform: device.platform,
reason,
unsentDeleted,
}, getClientIP(request));
if (body.drop === true) {
const dropped = await dropDevice(body.id, body.secret);
if (!dropped) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
if (dropped.confirmed) await write(dropped.confirmed, 'user', 'unknown');
return NextResponse.json({ ok: true }, { headers: NO_STORE });
}
if (!WIPE_REASONS.includes(body.reason as WipeReason)) return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
const reason = body.reason as WipeReason;
const unsent = typeof body.unsent === 'number' && Number.isFinite(body.unsent) && body.unsent >= 0 ? Math.floor(body.unsent) : 'unknown';
const answer = await confirmDeviceWipe(body.id, body.secret, reason);
if (!answer) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
if (answer === 'no_wipe_asked') return NextResponse.json({ error: 'no_wipe_asked' }, { status: 409, headers: NO_STORE });
if (answer.changed) await write(answer.device, reason, unsent);
return NextResponse.json({ ok: true }, { headers: NO_STORE });
} catch (error) {
if (isStoreUnavailable(error)) {
return NextResponse.json({ error: 'Temporarily unavailable' }, { status: 503, headers: { ...NO_STORE, 'Retry-After': '30' } });
}
logger.error('device wipe confirmation', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
}
}
+19
View File
@@ -102,3 +102,22 @@ describe('POST /api/errors', () => {
expect((await POST(post('not json'))).status).toBe(400);
});
});
describe('a desktop app\'s version', () => {
it.each(['0.3.0-beta.1+desktop', '1.20.0+desktop', '1.20.0-rc.2'])('keeps %s', async (version) => {
config.errorReportingDsn = DSN;
const res = await POST(post({ name: 'TypeError', frames: [], version }));
expect(res.status).toBe(204);
await flushErrors();
const day = (await readErrors())[utcDay()] ?? {};
expect(Object.values(day).map((entry) => entry.version)).toContain(version);
});
it.each(['1.20.0+a+b', '1.20.0-beta/../x', 'not a version'])('falls back to the server\'s for %s', async (version) => {
config.errorReportingDsn = DSN;
await POST(post({ name: 'RangeError', frames: [], version }));
await flushErrors();
const day = (await readErrors())[utcDay()] ?? {};
expect(Object.values(day).map((entry) => entry.version)).not.toContain(version);
});
});
+1 -1
View File
@@ -65,7 +65,7 @@ export async function POST(request: NextRequest) {
'browser',
// The version the tab runs (a tab left open across an update runs the
// old one, and its frames point into that); the server's when absent.
typeof body.version === 'string' && /^\d+\.\d+\.\d+(?:[-+][\w.-]{1,40})?$/.test(body.version) ? body.version : appVersion(),
typeof body.version === 'string' && /^\d+\.\d+\.\d+(?:-[\w.-]{1,40})?(?:\+[\w.-]{1,40})?$/.test(body.version) ? body.version : appVersion(),
);
countBrowserError();
return new NextResponse(null, { status: 204, headers: NO_STORE });
@@ -0,0 +1,70 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// Deleting everything of one user, which only makes sense where the server and its
// files are on the device: the desktop app. On an instance the same request is a
// way to delete someone else's connected accounts, so it has to do nothing.
const deleted = vi.hoisted(() => [] as unknown[]);
const session = vi.hoisted(() => ({ signedIn: false }));
vi.mock('@/lib/stalwart/credentials', () => ({
getStalwartCredentials: async () => (session.signedIn ? { accountName: 'me@example.org', serverUrl: 'https://mail.example.org', authHeader: 'x' } : null),
}));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled: true } }) },
}));
vi.mock('@/lib/plugin-secrets/vault', () => ({
VaultFullError: class extends Error {},
deleteAllUserCredentials: vi.fn(async (user: unknown) => { deleted.push(user); }),
deleteUserCredentials: vi.fn(async () => {}),
listUserCredentials: vi.fn(async () => []),
putUserCredential: vi.fn(async () => {}),
}));
async function remove(query: string, headers: Record<string, string> = { 'sec-fetch-site': 'same-origin' }): Promise<Response> {
vi.resetModules();
const { DELETE } = await import('../route');
return DELETE(new NextRequest(`https://app.local/api/plugin-secrets?${query}`, { method: 'DELETE', headers }));
}
const EVICTED = 'all=true&username=alice&server=https%3A%2F%2Fmail.example.org';
beforeEach(() => {
deleted.length = 0;
session.signedIn = false;
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('DELETE /api/plugin-secrets?all=true', () => {
it('in the desktop app takes a dropped account by who it was, with no cookie', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await remove(EVICTED)).status).toBe(200);
expect(deleted).toEqual([{ username: 'alice', serverUrl: 'https://mail.example.org' }]);
});
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);
expect(deleted).toEqual([]);
});
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([]);
});
it('in the desktop app with a cookie and no name takes the signed-in account', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
session.signedIn = true;
expect((await remove('all=true')).status).toBe(200);
expect(deleted).toEqual([{ username: 'me@example.org', serverUrl: 'https://mail.example.org' }]);
});
});
+28
View File
@@ -7,12 +7,14 @@ import { adminCredentialOrigins, adminCredentialStatus } from '@/lib/plugin-secr
import { declaredUserCredential } from '@/lib/plugin-secrets/known-plugins';
import {
VaultFullError,
deleteAllUserCredentials,
deleteUserCredentials,
listUserCredentials,
putUserCredential,
type VaultUser,
} from '@/lib/plugin-secrets/vault';
import { storedDataUnavailableResponse } from '@/lib/stored-data-errors';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const MAX_SECRET_LENGTH = 4096;
@@ -112,8 +114,34 @@ export async function POST(request: NextRequest) {
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.
const { searchParams } = request.nextUrl;
const owner = searchParams.get('username');
const server = searchParams.get('server');
if (IS_DESKTOP_BUILD && searchParams.get('all') === 'true' && owner && server) {
try {
await deleteAllUserCredentials({ username: owner, serverUrl: server });
return NextResponse.json({ ok: true }, { headers: NO_STORE });
} catch (error) {
const unavailable = storedDataUnavailableResponse(error, NO_STORE);
if (unavailable) return unavailable;
throw error;
}
}
const user = await currentUser(request);
if (user instanceof NextResponse) return user;
// A wipe in the desktop app, where this server and its files are on the device.
// On an instance they aren't the device's to remove.
if (IS_DESKTOP_BUILD && request.nextUrl.searchParams.get('all') === 'true') {
try {
await deleteAllUserCredentials(user);
return NextResponse.json({ ok: true }, { headers: NO_STORE });
} catch (error) {
const unavailable = storedDataUnavailableResponse(error, NO_STORE);
if (unavailable) return unavailable;
throw error;
}
}
const pluginId = request.nextUrl.searchParams.get('plugin') ?? '';
const credentialId = request.nextUrl.searchParams.get('credential') ?? undefined;
if (!PLUGIN_ID_RE.test(pluginId)) return bad('Invalid plugin id');
+9
View File
@@ -0,0 +1,9 @@
/*
* While the app lock is on and it isn't known yet to be unlocked, nothing under the
* lock screen shows, from the first frame. The inline script in app/(main)/layout.tsx
* sets the attribute before the first paint, and lib/app-lock/controller.ts takes it away.
* The window's drag strip stays: it shows the app's name and nothing else.
*/
html[data-app-locked] body > *:not([data-app-lock-overlay]):not(.window-title-bar) {
visibility: hidden !important;
}
+1 -2
View File
@@ -36,8 +36,7 @@ html[data-desktop] :is(
[role="treeitem"],
[role="row"],
[data-composer-header],
[data-mv-compose-titlebar],
[data-window-title-bar]
[data-mv-compose-titlebar]
) {
-webkit-user-select: none;
user-select: none;
+130 -10
View File
@@ -569,24 +569,144 @@ optgroup {
/* Mobile Responsive Utilities */
/*
* The top of the page in an installed app without a native title bar. 0
* without one: then --app-top-inset is exactly env(safe-area-inset-top), what
* the page roots used before. env() and not the display-mode media query,
* which is false in Electron with titleBarOverlay. Set at the root, so it
* holds from the first paint, for the boot skeletons too;
* lib/window-controls-overlay.ts keeps it up to date afterwards.
* An installed app or the desktop app with no native title bar. No row is kept
* for it: --titlebar-inset is 0, and the app's columns run to the top of the
* window. The window's controls sit in the top row of the column beneath them,
* which leaves them their room. env() and not the display-mode media query,
* which is false in Electron with titleBarOverlay. Set at the root, so it holds
* from the first paint, for the boot skeletons too;
* lib/window-controls-overlay.ts keeps it exact afterwards, and marks the rows.
*/
:root {
--titlebar-inset: env(titlebar-area-height, 0px);
--app-top-inset: max(env(safe-area-inset-top), var(--titlebar-inset));
--titlebar-inset: 0px;
--app-top-inset: env(safe-area-inset-top, 0px);
--titlebar-right-inset: calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw));
--titlebar-left-inset: env(titlebar-area-x, 0px);
--titlebar-row-height: env(titlebar-area-height, 0px);
}
/* components/layout/window-title-bar.tsx: what the window is dragged by. */
.window-title-bar {
/* A skeleton's row that will hold the controls has them from the first frame. */
html[data-window-controls] [data-window-row-right] {
padding-right: calc(var(--titlebar-right-inset) + 0.5rem) !important;
}
/* Something floating at the top end of a page with no row: next to the controls, lined up. */
html[data-window-controls]:not([dir="rtl"]) [data-window-beside-controls] {
right: calc(var(--titlebar-right-inset) + 0.5rem);
top: calc(var(--titlebar-row-height) / 2 - 1.25rem);
}
/* A column docked at the window's end starts below the controls, level with its neighbours' rows. */
html[data-window-controls]:not([data-interface-layout="mountain-view"]) [data-window-end-column] {
padding-top: var(--titlebar-row-height);
}
/* A composer that is its own window closes with the window's button, one row over. */
html[data-window-controls] [data-own-window-close] {
display: none;
}
/* A top row is at least as tall as the controls, so they don't hang over what's below it. */
html[data-window-controls] [data-window-row] {
min-height: var(--titlebar-row-height);
}
/*
* Mountain View's bar is the top row there, and lines up with the controls by being as
* tall. An installed PWA's controls are shorter than what's in that bar.
*/
html[data-window-controls] [data-mv-topbar] {
height: max(var(--titlebar-row-height), 3.5rem);
}
/* Pro's tab strip is the top row there: as tall as the controls, its tabs along the bottom edge. */
html[data-window-controls] [data-pane-strip] {
height: max(var(--titlebar-row-height), 2.25rem);
align-items: flex-end;
}
html[data-window-controls] [data-window-row-left] {
padding-left: calc(var(--titlebar-left-inset) + 0.5rem) !important;
}
/*
* The empty parts of the app's top rows are what the window is dragged by, and a
* double-click there maximises. Anything to click stays clickable, and so does
* whatever floats over a row: a region's drag wins over what's painted above it.
*
* Under the rows lies the band the controls have, invisible and first in the page, so
* a page with no row of its own (the login, an error) can still be moved. Everything
* later in the page cuts its own shape out of it.
*/
html[data-window-controls] body::before {
content: "";
position: fixed;
inset-inline: 0;
top: 0;
height: var(--titlebar-row-height);
pointer-events: none;
app-region: drag;
-webkit-app-region: drag;
}
html[data-window-controls] :is([data-window-row], [data-window-rail]) {
app-region: drag;
-webkit-app-region: drag;
}
/* A row that's only there to hold the controls: the settings page's name, for one. */
[data-window-only] {
display: none !important;
}
html[data-window-controls]:not([data-interface-layout="mountain-view"]) [data-window-only] {
display: flex !important;
}
html[data-window-controls] :is(
a,
button,
input,
label,
select,
summary,
textarea,
[contenteditable="true"],
[role="button"],
iframe,
[role="checkbox"],
[role="combobox"],
[role="link"],
[role="menu"],
[role="menuitem"],
[role="option"],
[role="searchbox"],
[role="switch"],
[role="tab"],
[role="treeitem"],
[tabindex]:not([tabindex="-1"]),
[draggable="true"]
) {
app-region: no-drag;
-webkit-app-region: no-drag;
}
html[data-window-controls] :is(.fixed, [role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], [role="tooltip"]) {
app-region: no-drag;
-webkit-app-region: no-drag;
}
/* A dialog's own top row, when it fills the window, is a row like any other. */
html[data-window-controls] :is(.fixed, [role="dialog"]) [data-window-row] {
app-region: drag;
-webkit-app-region: drag;
}
/* macOS: the traffic lights sit at the top of the rail, with room there only. */
html[data-window-controls-left] [data-window-rail] {
padding-top: var(--titlebar-row-height);
}
/* Safe area insets for notched devices (iPhone X+, etc.) */
.safe-area-inset-top {
padding-top: env(safe-area-inset-top);
+1 -1
View File
@@ -126,7 +126,7 @@ export default async function manifest(): Promise<ExtendedManifest> {
scope: withBase("/"),
display: "standalone",
// The page draws up to the top of the window, next to the native controls
// (components/layout/window-title-bar.tsx). `display` is the fallback.
// (components/layout/window-controls-host.tsx). `display` is the fallback.
display_override: ["window-controls-overlay", "standalone"],
orientation: "portrait-primary",
theme_color: themeColor,
+4
View File
@@ -442,6 +442,10 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly,
</aside>
<main className="flex min-w-0 flex-1 flex-col">
{/* Only for the window's controls: the sidebar says which page is open. */}
<div data-window-only="" aria-hidden="true" className="h-14 box-content shrink-0 items-center border-b border-border px-6 text-sm font-semibold">
<span className="truncate">{pageName ?? 'Admin'}</span>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl px-6 py-6">
{readOnlyBanner}
@@ -59,6 +59,22 @@ describe('Offline mode section', () => {
expect((puts[0].organisation as { devices: { noOffline: boolean } }).devices.noOffline).toBe(true);
});
it('leaves a refused login\'s data alone by default, and can ask for deletion at once, per scope', async () => {
await renderSection();
const toggle = screen.getByTestId('org-devices-wipeOnForcedSignOut');
expect(toggle.getAttribute('aria-checked')).toBe('false');
act(() => latest!.setScopeId('field'));
fireEvent.click(screen.getByTestId('org-devices-wipeOnForcedSignOut'));
expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { wipeOnForcedSignOut: true } });
expect(latest!.policy?.organisation?.devices.wipeOnForcedSignOut).toBe(false);
});
it('says what a sign-out does now, for the timers too', async () => {
await renderSection();
expect(document.body.textContent).not.toContain('Every sign-out, the automatic one after inactivity included, deletes everything');
expect(document.body.textContent).toContain('A sign-out someone asks for deletes everything');
});
it('writes a scope override only into that scope', async () => {
await renderSection();
act(() => latest!.setScopeId('field'));
@@ -50,6 +50,7 @@ const describeCap = (value: number, unit: string) => (value > 0 ? `${value} ${un
export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
const forbid = draft.orgValue('devices', 'noOffline');
const noAttachments = draft.orgValue('devices', 'noOfflineAttachments');
const wipeOnRefused = draft.orgValue('devices', 'wipeOnForcedSignOut');
return (
<SettingsSection
id="offline"
@@ -105,13 +106,22 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
<InheritNote draft={draft} own={noAttachments.own} everyone={noAttachments.everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'noOfflineAttachments')} />
</div>
</SettingItem>
<SettingItem
label="Delete a device's mail as soon as it's signed out with nobody asking"
description="That's when the server refuses its login, or the idle or session limit runs out. Off: the device signs out and hides what it keeps, unsent messages included, until the next sign-in, or for 30 days. That's what a password changed on another device needs. On: it's deleted right away, unsent messages included, and only that account's. A wipe you ask for below is immediate either way."
>
<div className="flex flex-col items-start gap-1 sm:items-end">
<ToggleSwitch checked={wipeOnRefused.value} onChange={(v) => draft.setOrgValue('devices', 'wipeOnForcedSignOut', v)} testId="org-devices-wipeOnForcedSignOut" />
<InheritNote draft={draft} own={wipeOnRefused.own} everyone={wipeOnRefused.everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'wipeOnForcedSignOut')} />
</div>
</SettingItem>
<p className="pt-2 text-xs text-muted-foreground">
What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything),
with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not
against someone using the unlocked browser or app. The limits above can only lower what people pick: where several
scopes apply, the lowest value counts, so a scope can&apos;t raise what another one lowered. The app holds itself to
them when it fetches the policy; the server can&apos;t check what is on a device. Every sign-out, the automatic one
after inactivity included, deletes everything. With several accounts signed in, each account follows its own settings.
them when it fetches the policy; the server can&apos;t check what is on a device. A sign-out someone asks for deletes everything.
One that nobody asked for hides it, as the switch above says. With several accounts signed in, each account follows its own settings.
Bulwark Lite reads the same policy but has no offline mode.{' '}
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-2">
How offline mode works
@@ -0,0 +1,217 @@
import 'fake-indexeddb/auto';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AppLockSettings } from '@/components/settings/app-lock-settings';
import { LockScreen } from '../lock-screen';
import { createVault, deleteVault, FREE_FAILURES } from '@/lib/app-lock/vault';
import { writeAppLockFile } from '@/lib/app-lock/state';
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) }));
const unlockWithPin = vi.hoisted(() => vi.fn());
const forgetEverything = vi.hoisted(() => vi.fn());
vi.mock('@/lib/app-lock/controller', async (original) => ({
...(await original<typeof import('@/lib/app-lock/controller')>()),
unlockWithPin,
forgetEverything,
}));
function setPolicy(required: boolean, minutes: number | null, locked: boolean): void {
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = {
version: 1,
platform: 'win32',
policy: {
get: () => ({
AppLockRequired: { value: required, locked: required, source: required ? 'forced-machine' : 'default' },
AppLockTimeoutMinutes: { value: minutes, locked, source: locked ? 'forced-machine' : 'default' },
}),
onChange: () => () => {},
},
};
}
beforeEach(async () => {
await deleteVault();
localStorage.clear();
unlockWithPin.mockReset();
forgetEverything.mockReset();
});
afterEach(() => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
function typeAndSubmit(pin: string): void {
fireEvent.change(screen.getByTestId('app-lock-pin'), { target: { value: pin } });
fireEvent.submit(screen.getByTestId('app-lock-form'));
}
describe('the lock screen', () => {
it('passes the PIN on and shows nothing but the lock', async () => {
(await createVault('correct-pin')).fill(0);
unlockWithPin.mockResolvedValue(undefined);
render(<LockScreen phase="locked" />);
typeAndSubmit('correct-pin');
await waitFor(() => expect(unlockWithPin).toHaveBeenCalledWith('correct-pin'));
expect(screen.getByRole('dialog')).toHaveAttribute('data-app-lock-overlay');
});
it('says a wrong PIN was wrong, and empties the field', async () => {
(await createVault('correct-pin')).fill(0);
unlockWithPin.mockRejectedValue(Object.assign(new Error('x'), { name: 'WrongPinError' }));
render(<LockScreen phase="locked" />);
typeAndSubmit('nope-nope');
await waitFor(() => expect(screen.getByTestId('app-lock-message')).toHaveTextContent('wrong_pin'));
expect(screen.getByTestId('app-lock-pin')).toHaveValue('');
});
it('comes up blocked after a reload, from what is on disk', async () => {
(await createVault('correct-pin')).fill(0);
const { openVaultWithPin } = await import('@/lib/app-lock/vault');
for (let i = 0; i <= FREE_FAILURES; i += 1) await openVaultWithPin('wrong-pin').catch(() => {});
await act(async () => {
render(<LockScreen phase="locked" />);
await new Promise((resolve) => setTimeout(resolve, 20));
});
await waitFor(() => expect(screen.getByTestId('app-lock-pin')).toBeDisabled());
expect(screen.getByTestId('app-lock-message').textContent).toMatch(/blocked_/);
expect(screen.getByTestId('app-lock-unlock')).toBeDisabled();
});
it('offers no passkey or OS button when none is set up, and always the way out', async () => {
(await createVault('correct-pin')).fill(0);
render(<LockScreen phase="locked" />);
await waitFor(() => expect(screen.getByTestId('app-lock-forgot')).toBeInTheDocument());
expect(screen.queryByTestId('app-lock-passkey')).toBeNull();
expect(screen.queryByTestId('app-lock-os')).toBeNull();
});
it('asks about a forgotten PIN inside the lock screen: everything on <body> beside it is hidden', async () => {
(await createVault('correct-pin')).fill(0);
render(<LockScreen phase="locked" />);
fireEvent.click(await screen.findByTestId('app-lock-forgot'));
const confirm = screen.getByRole('button', { name: 'forgot_confirm' });
expect(confirm.closest('[data-app-lock-overlay]')).not.toBeNull();
expect(screen.getByRole('dialog')).toHaveTextContent('forgot_body');
fireEvent.click(screen.getByRole('button', { name: 'cancel' }));
expect(await screen.findByTestId('app-lock-pin')).toBeInTheDocument();
expect(forgetEverything).not.toHaveBeenCalled();
});
it('forgets only after the yes, and comes back when main says no', async () => {
(await createVault('correct-pin')).fill(0);
forgetEverything.mockResolvedValue(false);
render(<LockScreen phase="locked" />);
fireEvent.click(await screen.findByTestId('app-lock-forgot'));
fireEvent.click(screen.getByRole('button', { name: 'forgot_confirm' }));
await waitFor(() => expect(forgetEverything).toHaveBeenCalledTimes(1));
expect(await screen.findByTestId('app-lock-pin')).toBeInTheDocument();
});
describe('in a pop-out', () => {
class Channel {
static open = new Set<Channel>();
onmessage: ((event: MessageEvent) => void) | null = null;
constructor() { Channel.open.add(this); }
postMessage(data: unknown): void {
for (const channel of Channel.open) if (channel !== this) channel.onmessage?.({ data } as MessageEvent);
}
close(): void { Channel.open.delete(this); }
}
beforeEach(() => {
Channel.open.clear();
vi.stubGlobal('BroadcastChannel', Channel);
window.history.replaceState(null, '', '/popout/compose');
});
afterEach(() => {
vi.unstubAllGlobals();
window.history.replaceState(null, '', '/');
});
it('sends people to the main window, and asks for the PIN itself once that has closed', async () => {
(await createVault('correct-pin')).fill(0);
const main = new Channel();
let mainOpen = true;
main.onmessage = (event) => {
if (mainOpen && (event.data as { type: string }).type === 'main-ping') main.postMessage({ type: 'main-pong' });
};
render(<LockScreen phase="locked" />);
expect(await screen.findByRole('button', { name: 'open_main' })).toBeInTheDocument();
mainOpen = false;
main.close();
expect(await screen.findByTestId('app-lock-pin', {}, { timeout: 6000 })).toBeInTheDocument();
}, 15_000);
});
it('says it\'s saving rather than ask for anything while it locks', () => {
render(<LockScreen phase="locking" />);
expect(screen.queryByTestId('app-lock-pin')).toBeNull();
expect(screen.getByRole('dialog')).toHaveTextContent('locking');
});
it('asks for a PIN when an organisation requires it, and only a good one', () => {
render(<LockScreen phase="setup" />);
expect(screen.getByRole('dialog')).toHaveTextContent('setup_required_title');
const save = screen.getByRole('button', { name: 'save' });
expect(save).toBeDisabled();
fireEvent.change(screen.getByTestId('app-lock-new-pin'), { target: { value: '12345' } });
fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '12345' } });
expect(save).toBeDisabled();
fireEvent.change(screen.getByTestId('app-lock-new-pin'), { target: { value: '482913' } });
expect(save).toBeDisabled();
fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '482913' } });
expect(save).toBeEnabled();
});
});
describe('the settings', () => {
it('say what a PIN protects against here', () => {
render(<AppLockSettings />);
expect(screen.getByText('protects_desc')).toBeInTheDocument();
});
it('won\'t save a PIN anyone would guess', async () => {
render(<AppLockSettings />);
fireEvent.click(screen.getByTestId('app-lock-switch'));
fireEvent.change(await screen.findByTestId('app-lock-new-pin'), { target: { value: '123456' } });
fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '123456' } });
expect(screen.getByText('pin_obvious')).toBeInTheDocument();
expect(screen.getByTestId('app-lock-dialog-save')).toBeDisabled();
});
it('are off by default, with nothing else shown', () => {
render(<AppLockSettings />);
expect(screen.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false');
expect(screen.queryByTestId('app-lock-now')).toBeNull();
expect(screen.queryByTestId('app-lock-timeout')).toBeNull();
});
it('show the switch as set by the organisation when a policy requires it', () => {
setPolicy(true, null, false);
writeAppLockFile({ on: true, locked: false });
const { container } = render(<AppLockSettings />);
const toggle = screen.getByTestId('app-lock-switch');
expect(toggle).toHaveAttribute('aria-checked', 'true');
expect(toggle.closest('[inert]')).not.toBeNull();
expect(container.textContent).toContain('org_locked');
});
it('show a forced timeout as set by the organisation, with its value', () => {
setPolicy(false, 3, true);
writeAppLockFile({ on: true, locked: false, timeoutMinutes: 60 });
render(<AppLockSettings />);
const select = screen.getByTestId('app-lock-timeout');
expect(select).toHaveValue('3');
expect(select.closest('[inert]')).not.toBeNull();
});
it('leave the timeout to the user otherwise', () => {
writeAppLockFile({ on: true, locked: false, timeoutMinutes: 30 });
render(<AppLockSettings />);
const select = screen.getByTestId('app-lock-timeout');
expect(select).toHaveValue('30');
expect(select.closest('[inert]')).toBeNull();
});
});
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { Suspense, use, useEffect, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { LockScreen } from "./lock-screen";
import { appLockHold, bootAppLock, getAppLockPhase, recheckAppLockSetup, startAppLock, subscribeAppLockPhase, takeDraftsSavedNotice, type AppLockPhase } from "@/lib/app-lock/controller";
import { IS_LITE } from "@/lib/lite";
import { useAccountStore } from "@/stores/account-store";
import { toast } from "@/stores/toast-store";
import { useTranslations } from "next-intl";
const serverPhase = (): AppLockPhase => "open";
/**
* Suspends the app below while the lock holds. In the first render of a page that is
* locked, that keeps the server's markup inert: nothing below hydrates, so no effect runs,
* no store fills and no plugin frame starts, until someone unlocks in this same document.
* The server's markup is the empty skeleton, never mail.
*/
function Hold() {
if (typeof window === "undefined") return null;
bootAppLock();
const hold = appLockHold();
if (hold) use(hold);
return null;
}
/** Above everything that touches mail, and in every window: main, pop-outs, composers. */
export function AppLockGate({ children }: { children: React.ReactNode }) {
const phase = useSyncExternalStore(subscribeAppLockPhase, getAppLockPhase, serverPhase);
const t = useTranslations("appLock");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return startAppLock();
}, []);
// Signing in while an organisation requires the lock: it's due now.
useEffect(() => {
if (IS_LITE) return;
recheckAppLockSetup();
return useAccountStore.subscribe(recheckAppLockSetup);
}, []);
useEffect(() => {
if (phase === "open" && takeDraftsSavedNotice()) toast.info(t("draft_saved_notice"));
}, [phase, t]);
if (IS_LITE) return <>{children}</>;
return (
<>
<Suspense fallback={null}>
<Hold />
{phase === "locked-stay" ? null : children}
</Suspense>
{mounted && phase !== "open" && phase !== "checking" && createPortal(<LockScreen phase={phase} />, document.body)}
</>
);
}
+289
View File
@@ -0,0 +1,289 @@
"use client";
import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from "react";
import { useTranslations } from "next-intl";
import { Lock } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useConfig } from "@/hooks/use-config";
import { NewPinFields, newPinReady } from "./pin-fields";
import { enableAppLock, forgetEverything, unlockWithOs, unlockWithPasskey, unlockWithPin, type AppLockPhase } from "@/lib/app-lock/controller";
import { passkeysPossible } from "@/lib/app-lock/passkey";
import { APP_LOCK_CHANNEL, postAppLockMessage, type AppLockMessage } from "@/lib/app-lock/state";
import { pinBlockedUntil, readVault } from "@/lib/app-lock/vault";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopOsUnlockKind } from "@/lib/desktop/types";
import { isPopoutWindow } from "@/lib/popout/window-role";
import { productName } from "@/lib/product-name";
/** How often a pop-out asks whether a main window is there, and how long a silence means it's gone. */
const MAIN_PING_MS = 1_000;
const MAIN_GONE_MS = 2_500;
function secondsLeft(until: number): number {
return Math.max(0, Math.ceil((until - Date.now()) / 1000));
}
function useBlockedUntil(): [number, (until: number) => void] {
const [until, setUntil] = useState(0);
const [, tick] = useState(0);
useEffect(() => {
void pinBlockedUntil().then(setUntil).catch(() => {});
}, []);
useEffect(() => {
if (until <= Date.now()) return;
const timer = setInterval(() => tick((n) => n + 1), 1000);
return () => clearInterval(timer);
}, [until]);
return [until > Date.now() ? until : 0, setUntil];
}
/**
* Whether a main window is there. The tab that unlocks keeps the key for as long as it lives,
* and a pop-out is about to close: so a pop-out sends people to the main window. It keeps
* asking, because a main window that closes or crashes says nothing, and with none left the
* pop-out has to take the PIN itself or nobody can get in.
*/
function useMainWindowThere(): boolean {
const [there, setThere] = useState(false);
useEffect(() => {
if (!isPopoutWindow() || typeof BroadcastChannel === "undefined") return;
const channel = new BroadcastChannel(APP_LOCK_CHANNEL);
let heardAt = 0;
channel.onmessage = (event: MessageEvent<AppLockMessage>) => {
if (event.data.type !== "main-pong") return;
heardAt = Date.now();
setThere(true);
};
postAppLockMessage({ type: "main-ping" });
const timer = setInterval(() => {
if (Date.now() - heardAt > MAIN_GONE_MS) setThere(false);
postAppLockMessage({ type: "main-ping" });
}, MAIN_PING_MS);
return () => {
clearInterval(timer);
channel.close();
};
}, []);
return there;
}
function Frame({ title, body, children }: { title: string; body?: string; children: React.ReactNode }) {
const titleId = useId();
return (
<div
data-app-lock-overlay=""
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="fixed inset-0 z-[100001] flex items-center justify-center overflow-y-auto bg-background p-6 pt-[calc(var(--titlebar-inset,0px)+1.5rem)]"
>
<div className="w-full max-w-sm space-y-5">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-muted text-foreground">
<Lock className="h-6 w-6" aria-hidden="true" />
</span>
<h1 id={titleId} className="text-lg font-semibold text-foreground">{title}</h1>
{body && <p className="text-sm text-muted-foreground">{body}</p>}
</div>
{children}
</div>
</div>
);
}
function SetupForm() {
const t = useTranslations("appLock");
const appName = productName(useConfig().appName);
const [pin, setPin] = useState("");
const [repeat, setRepeat] = useState("");
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!newPinReady(pin, repeat) || busy) return;
setBusy(true);
setFailed(false);
try {
await enableAppLock(pin);
} catch {
setFailed(true);
setBusy(false);
}
};
return (
<Frame title={t("setup_required_title")} body={t("setup_required_body", { app: appName })}>
<form onSubmit={submit} className="space-y-4" data-testid="app-lock-setup">
<NewPinFields pin={pin} repeat={repeat} onPin={setPin} onRepeat={setRepeat} />
{failed && <p role="alert" className="text-sm text-destructive">{t("save_failed")}</p>}
<Button type="submit" className="w-full" disabled={busy || !newPinReady(pin, repeat)}>{t("save")}</Button>
</form>
</Frame>
);
}
function UnlockForm() {
const t = useTranslations("appLock");
const appName = productName(useConfig().appName);
const [forgetting, setForgetting] = useState(false);
const [pin, setPin] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [blockedUntil, setBlockedUntil] = useBlockedUntil();
const [hasPasskey, setHasPasskey] = useState(false);
const [osKind, setOsKind] = useState<DesktopOsUnlockKind | null>(null);
const mainThere = useMainWindowThere();
const inputRef = useRef<HTMLInputElement>(null);
const pinId = useId();
useEffect(() => {
void readVault().then((vault) => setHasPasskey(Boolean(vault?.prf) && passkeysPossible())).catch(() => {});
void getDesktopBridge()?.appLock?.status().then((status) => setOsKind(status.osEnrolled ? status.os : null)).catch(() => {});
}, []);
const blocked = blockedUntil > 0;
const left = secondsLeft(blockedUntil);
const submit = useCallback(async (event: FormEvent) => {
event.preventDefault();
if (!pin || busy || blocked) return;
setBusy(true);
setMessage(null);
try {
await unlockWithPin(pin);
} catch (error) {
const name = (error as Error)?.name;
setBusy(false);
setPin("");
inputRef.current?.focus();
if (name === "PinBlockedError") setBlockedUntil((error as Error & { until: number }).until);
else if (name === "WrongPinError") {
setMessage(t("wrong_pin"));
void pinBlockedUntil().then(setBlockedUntil).catch(() => {});
} else setMessage(t("unlock_failed"));
}
}, [blocked, busy, pin, setBlockedUntil, t]);
const withOs = async () => {
setBusy(true);
setMessage(null);
try {
if (await unlockWithOs()) return;
} catch {
// The PIN below still works.
}
setBusy(false);
setMessage(t("os_failed"));
};
const withPasskey = async () => {
setBusy(true);
setMessage(null);
try {
await unlockWithPasskey();
} catch {
setBusy(false);
setMessage(t("passkey_failed"));
}
};
// Here and not in a dialog: a dialog is a portal on <body>, which the lock keeps hidden.
if (forgetting) {
return (
<Frame title={t("forgot_title")} body={t("forgot_body")}>
<p className="text-center text-sm text-muted-foreground">{t("forgot_provider")}</p>
<div className="flex flex-col gap-2" data-testid="app-lock-forget">
<Button
variant="destructive"
className="w-full"
disabled={busy}
onClick={() => {
setBusy(true);
void forgetEverything().catch(() => false).then((gone) => {
if (gone) return;
setBusy(false);
setForgetting(false);
});
}}
>
{t("forgot_confirm")}
</Button>
<Button variant="ghost" className="w-full" disabled={busy} onClick={() => setForgetting(false)}>{t("cancel")}</Button>
</div>
</Frame>
);
}
if (mainThere) {
return (
<Frame title={t("lock_title", { app: appName })} body={t("unlock_in_main")}>
<Button
className="w-full"
onClick={() => {
const windows = getDesktopBridge()?.windows;
if (windows?.focus) windows.focus();
else postAppLockMessage({ type: "focus-main" });
}}
>
{t("open_main")}
</Button>
</Frame>
);
}
return (
<Frame title={t("lock_title", { app: appName })} body={t("pin_prompt")}>
<form onSubmit={submit} className="space-y-3" data-testid="app-lock-form">
<label htmlFor={pinId} className="sr-only">{t("pin_label")}</label>
<Input
id={pinId}
ref={inputRef}
data-testid="app-lock-pin"
type="password"
autoComplete="off"
autoFocus
value={pin}
onChange={(event) => setPin(event.target.value)}
placeholder={t("pin_label")}
disabled={blocked}
/>
<div role="status" aria-live="polite" className="min-h-5 text-sm text-destructive" data-testid="app-lock-message">
{blocked
? (left >= 60 ? t("blocked_minutes", { count: Math.ceil(left / 60) }) : t("blocked_seconds", { count: left }))
: message}
</div>
<Button type="submit" className="w-full" disabled={!pin || busy || blocked} data-testid="app-lock-unlock">
{busy ? t("unlocking") : t("unlock")}
</Button>
{osKind && (
<Button type="button" variant="outline" className="w-full" disabled={busy} onClick={withOs} data-testid="app-lock-os">
{osKind === "hello" ? t("use_hello") : t("use_touchid")}
</Button>
)}
{hasPasskey && (
<Button type="button" variant="outline" className="w-full" disabled={busy} onClick={withPasskey} data-testid="app-lock-passkey">
{t("use_passkey")}
</Button>
)}
<Button type="button" variant="ghost" className="w-full" onClick={() => setForgetting(true)} data-testid="app-lock-forgot">
{t("forgot")}
</Button>
</form>
</Frame>
);
}
/** What covers a window while the app is locked. Reads nothing but the lock's own records. */
export function LockScreen({ phase }: { phase: AppLockPhase }) {
const t = useTranslations("appLock");
const appName = productName(useConfig().appName);
if (phase === "setup") return <SetupForm />;
if (phase === "locking") {
return (
<Frame title={t("lock_title", { app: appName })} body={t("locking")}>
<span />
</Frame>
);
}
return <UnlockForm />;
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useId } from "react";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input";
import { pinIsObvious, pinLongEnough } from "@/lib/app-lock/crypto";
/** The new PIN, twice. Says what's wrong only once there's something to say. */
export function NewPinFields({ pin, repeat, onPin, onRepeat }: {
pin: string;
repeat: string;
onPin: (value: string) => void;
onRepeat: (value: string) => void;
}) {
const t = useTranslations("appLock");
const pinId = useId();
const repeatId = useId();
const hintId = useId();
const tooShort = pin.length > 0 && !pinLongEnough(pin);
const obvious = pinLongEnough(pin) && pinIsObvious(pin);
const mismatch = repeat.length > 0 && repeat !== pin;
return (
<div className="space-y-3">
<div className="space-y-1">
<label htmlFor={pinId} className="text-sm font-medium text-foreground">{t("pin_new")}</label>
<Input
id={pinId}
data-testid="app-lock-new-pin"
type="password"
autoComplete="new-password"
value={pin}
onChange={(event) => onPin(event.target.value)}
aria-describedby={hintId}
aria-invalid={tooShort || obvious || undefined}
/>
<p id={hintId} className="text-xs text-muted-foreground">{t("pin_hint")}</p>
{obvious && <p role="alert" className="text-xs text-destructive">{t("pin_obvious")}</p>}
</div>
<div className="space-y-1">
<label htmlFor={repeatId} className="text-sm font-medium text-foreground">{t("pin_repeat")}</label>
<Input
id={repeatId}
data-testid="app-lock-repeat-pin"
type="password"
autoComplete="new-password"
value={repeat}
onChange={(event) => onRepeat(event.target.value)}
aria-invalid={mismatch || undefined}
/>
{mismatch && <p role="alert" className="text-xs text-destructive">{t("pin_mismatch")}</p>}
</div>
</div>
);
}
export function newPinReady(pin: string, repeat: string): boolean {
return pinLongEnough(pin) && !pinIsObvious(pin) && pin === repeat;
}
/** A PIN already set, asked again before something that needs it. */
export function CurrentPinField({ value, onChange, error }: { value: string; onChange: (value: string) => void; error?: string | null }) {
const t = useTranslations("appLock");
const id = useId();
return (
<div className="space-y-1">
<label htmlFor={id} className="text-sm font-medium text-foreground">{t("pin_current")}</label>
<Input
id={id}
data-testid="app-lock-current-pin"
type="password"
autoComplete="off"
value={value}
onChange={(event) => onChange(event.target.value)}
aria-invalid={error ? true : undefined}
/>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
</div>
);
}
@@ -227,6 +227,9 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla
</div>
<div className="flex min-w-0 flex-1 flex-col">
<div data-window-only="" data-window-row-right="" className="h-14 box-content shrink-0 items-center border-b border-border px-6">
<TextLine className="text-sm" w="6rem" />
</div>
<div className="flex-1 overflow-hidden">
<div className="mx-auto max-w-3xl px-6 py-6">
<AdminContentSkeleton page={page} />
@@ -176,7 +176,7 @@ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) {
const views = text?.views;
return (
<>
<div className="hidden md:flex items-center gap-1 px-3 h-14 box-content border-b border-border flex-shrink-0">
<div data-window-row-right="" className="hidden md:flex items-center gap-1 px-3 h-14 box-content border-b border-border flex-shrink-0">
<ToolbarIcon className="me-1">
<Menu />
</ToolbarIcon>
+1 -1
View File
@@ -199,7 +199,7 @@ function FilesSidebarSkeleton() {
function FilesHeaderSkeleton({ picker }: { picker: boolean }) {
const on = "bg-primary/10 text-primary";
return (
<div className="flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0">
<div data-window-row-right="" className="flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0">
{picker ? null : <ToolIcon icon={Menu} className="lg:hidden" />}
<div className="flex-1 min-w-0 flex items-center text-sm">
<span className="flex items-center px-1.5 py-1 h-7">
+1 -1
View File
@@ -65,7 +65,7 @@ export function LoginSkeleton({ label, accessibilityButton = false, className }:
)}
>
{/* The theme menu button: its icon, and the theme's name from sm up. */}
<div aria-hidden="true" className="absolute top-5 end-5">
<div aria-hidden="true" data-window-beside-controls="" className="absolute top-5 end-5">
<span className="flex items-center gap-2 px-3 py-2 rounded-xl border text-sm bg-background/60 border-border/50">
<Block className="w-4 h-4 rounded" />
<TextLine className="hidden sm:block w-11" w="100%" />
+1 -1
View File
@@ -411,7 +411,7 @@ export function MailMessageToolbarSkeleton({ end }: { end?: ReactNode } = {}) {
const button = "h-8 w-20 rounded-md";
return (
<div className="bg-background flex-shrink-0 border-b border-border">
<div className="px-2 sm:px-4 lg:px-6 flex items-center h-14">
<div data-window-row-right="" className="px-2 sm:px-4 lg:px-6 flex items-center h-14">
{/* Phones: back, then icon-over-label actions. */}
<div className="sm:hidden flex items-center justify-between gap-0.5 w-full">
<span className="flex items-center justify-center h-9 w-9 -ms-1 flex-shrink-0">
@@ -34,6 +34,8 @@ function TabStripSkeleton() {
return (
<div
aria-hidden="true"
data-pane-strip=""
data-window-row-right=""
className="relative flex items-stretch h-9 bg-secondary px-1 overflow-hidden flex-shrink-0"
style={STRIP_BORDER}
>
@@ -163,7 +163,7 @@ function SettingsPage({ sections }: { sections: number }) {
/** A phone header row: back arrow, title, then the ⋮ menu and the avatar (PhoneHeaderActions). */
function PhoneHeader({ list }: { list: boolean }) {
return (
<div className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<div data-window-row-right="" className="flex items-center gap-2 px-4 h-14 border-b border-border bg-background shrink-0">
<span className="grid place-items-center h-11 w-11 shrink-0">
<ArrowLeft className="w-5 h-5" />
</span>
@@ -270,6 +270,11 @@ export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean
<div className={cn("flex-1 min-w-0 min-h-0", embedded ? "flex" : "hidden lg:flex")}>
<SettingsNav embedded={embedded} />
<div aria-hidden="true" className="flex-1 min-w-0 overflow-hidden">
{embedded ? null : (
<div data-window-only="" data-window-row-right="" className="items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0">
<Line className="text-sm" bar="h-3.5" w="6rem" />
</div>
)}
<div className="max-w-3xl mx-auto px-6 py-6">
<SettingsPage sections={3} />
</div>
+1 -1
View File
@@ -225,7 +225,7 @@ export function CalendarToolbar({
<div className="border-b border-border">
{/* ── MOBILE TOOLBAR ── */}
{isMobile && (
<div className="flex flex-col gap-1 px-2 py-2">
<div data-window-top-row="" className="flex flex-col gap-1 px-2 py-2">
{/* Row 1 while searching: the search box with a way back. */}
{mobileSearchField ? (
<div className="flex items-center gap-1">
+1 -1
View File
@@ -1037,7 +1037,7 @@ export function EventModal({
<div className="h-1 w-full flex-shrink-0" style={{ backgroundColor: color }} />
{/* Header */}
<div className="flex items-start justify-between gap-2 px-6 py-4 border-b border-border flex-shrink-0">
<div data-window-top-row="" className="flex items-start justify-between gap-2 px-6 py-4 border-b border-border flex-shrink-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
@@ -0,0 +1,76 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { getPathPrefix } from "@/lib/browser-navigation";
import { reminderTimeZone, useMissedReminders } from "@/lib/calendar-reminders/state";
import { dismissReminders, joinLabel, joinMeeting } from "@/lib/desktop/reminder-notifications";
import { useSettingsStore } from "@/stores/settings-store";
/** What came due while the app was closed, the machine slept or notifications were paused. */
export function MissedRemindersDialog() {
const t = useTranslations("desktopReminders");
const tAlert = useTranslations("calendar.notifications");
const locale = useLocale();
const timeZone = useSettingsStore((s) => s.timeZone);
const account = useMissedReminders((s) => s.account);
const alerts = useMissedReminders((s) => s.alerts);
const close = useMissedReminders((s) => s.close);
const joinLabels = { join: t("join"), joinHost: (host: string) => t("join_host", { host }) };
if (!account || alerts.length === 0) return null;
const format = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: reminderTimeZone(timeZone) });
const finish = () => {
dismissReminders(account, alerts.map((alert) => alert.key));
close();
};
return (
<Dialog
isOpen
onClose={finish}
title={t("missed_dialog_title")}
description={t("missed_dialog_description")}
footer={(
<>
<Button
variant="outline"
onClick={() => {
finish();
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
}}
>
{t("open_calendar")}
</Button>
<Button onClick={finish}>{t("done")}</Button>
</>
)}
>
<ul className="max-h-80 overflow-y-auto divide-y divide-border" data-testid="missed-reminders">
{alerts.map((alert) => (
<li key={alert.key} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{alert.title || tAlert("alert_title")}</p>
<p className="truncate text-xs text-muted-foreground">
{format.format(new Date(alert.startMs ?? alert.fireTimeMs))}
{alert.calendarName ? ` · ${alert.calendarName}` : ""}
</p>
</div>
{alert.joinUrl && (
<Button
variant="outline"
size="sm"
aria-label={t("join_named", { title: alert.title || tAlert("alert_title") })}
onClick={() => void joinMeeting(alert.joinUrl!)}
>
{joinLabel(alert, joinLabels)}
</Button>
)}
</li>
))}
</ul>
</Dialog>
);
}
+1 -1
View File
@@ -222,7 +222,7 @@ export function TaskModal({
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div data-window-top-row="" className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 id={`${id}-heading`} className="text-sm font-semibold">
{isEdit ? t("tasks.edit") : t("tasks.create")}
</h2>
+4 -2
View File
@@ -99,7 +99,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
if (!contact) {
return (
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
<div className={cn("relative flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
{/* The window's controls lie over this end, and its empty top is something to drag by. */}
<div aria-hidden="true" data-window-top-row="" className="absolute inset-x-0 top-0 h-14" />
<BookUser className="w-16 h-16 mb-4 opacity-20" />
<p className="text-sm">{t("detail.no_contact_selected")}</p>
</div>
@@ -139,7 +141,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div data-window-top-row="" className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "flex-wrap items-start justify-between")}>
<div className={cn("flex items-center gap-4 min-w-0 flex-1", !isMobile && "basis-60")}>
<Avatar name={name} email={email} contactPhoto={photo} size={isMobile ? "md" : "lg"} />
+1 -1
View File
@@ -704,7 +704,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
// The app checks the fields itself and says what is wrong inline; the
// browser's own bubble covered that message.
<form onSubmit={handleSubmit} noValidate className="flex flex-col h-full bg-background">
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<div data-window-top-row="" className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
{isEditing ? t("edit_title") : t("create_title")}
</h2>
+1 -1
View File
@@ -48,7 +48,7 @@ export function ContactGroupDetail({
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div data-window-top-row="" className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4">
<div className={cn("rounded-full bg-primary/10 flex items-center justify-center", isMobile ? "w-12 h-12" : "w-14 h-14")}>
+1 -1
View File
@@ -88,7 +88,7 @@ export function ContactGroupForm({
return (
<form onSubmit={handleSubmit} className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border">
<div data-window-top-row="" className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">
{isEditing ? t("groups.edit") : t("groups.create")}
</h2>
+5 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { isAppLockedNow } from "@/lib/app-lock/state";
import { AppMain, SkipLink } from "@/components/layout/app-landmarks";
import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useLocale, useTranslations } from "next-intl";
@@ -231,7 +232,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// Leaving the page or going Back asks too.
useEffect(() => {
if (!editorDirty) return;
const onBeforeUnload = (e: BeforeUnloadEvent) => e.preventDefault();
const onBeforeUnload = (e: BeforeUnloadEvent) => {
// The app lock asks nobody: the page is going to the lock screen.
if (!isAppLockedNow()) e.preventDefault();
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [editorDirty]);
@@ -0,0 +1,313 @@
import { act, cleanup, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
import { registerDesktopSurface, resetDesktopCommands } from '@/lib/desktop/commands';
import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '@/lib/desktop/preferences';
import { resetDesktopLanguages } from '@/lib/desktop/languages';
import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
import { useToastStore } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { useLocaleStore } from '@/stores/locale-store';
import { useOfflineStore } from '@/lib/offline/state';
const push = vi.fn();
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) }));
const canPrint = vi.fn(() => true);
const canDownload = vi.fn(() => true);
vi.mock('@/lib/device-policy', () => ({ canPrintAnywhere: () => canPrint(), canDownloadAnywhere: () => canDownload() }));
vi.mock('@/hooks/use-device-policy', () => ({ useStrictestDevices: () => strictest.current }));
const strictest = vi.hoisted(() => ({ current: {} as object }));
const resolveExternalLink = vi.fn(async (ctx: { href: string }) => ctx.href as string | null);
vi.mock('@/lib/plugin-hook-result', () => ({ resolveExternalLink: (ctx: { href: string }) => resolveExternalLink(ctx) }));
const requestInternalMailto = vi.fn((_raw: string) => true);
vi.mock('@/lib/protocol-handlers/mailto', () => ({ requestInternalMailto: (raw: string) => requestInternalMailto(raw) }));
const allowed = vi.hoisted(() => ({ dictionary: true }));
vi.mock('@/lib/desktop/dictionary-download', () => ({ dictionaryDownloadAllowed: () => allowed.dictionary }));
const signOut = vi.hoisted(() => ({ full: [] as (() => void)[], before: [] as (() => Promise<void>)[] }));
vi.mock('@/lib/sign-out-cleanup', async (original) => ({
...(await original<object>()),
onFullSignOut: (task: () => void) => signOut.full.push(task),
onBeforeSignOut: (task: () => Promise<void>) => signOut.before.push(task),
}));
const { DesktopHost } = await import('../desktop-host');
function fakeBridge() {
let command: ((id: string) => void) | null = null;
let flush: (() => Promise<unknown>) | null = null;
let missing: ((language: string) => void) | null = null;
const parts = {
setState: vi.fn(),
setLanguages: vi.fn(),
sessionReady: vi.fn(),
setRestoreEnabled: vi.fn(),
setTheme: vi.fn(),
openExternal: vi.fn(),
allowDownload: vi.fn(),
clearFiles: vi.fn(),
};
const bridge = {
version: 1,
platform: 'win32',
menu: { setState: parts.setState, onCommand: (listener: (id: string) => void) => { command = listener; return () => {}; } },
shell: { openExternal: parts.openExternal, setLanguages: parts.setLanguages },
spellcheck: { missing: async () => [], onMissing: (listener: (language: string) => void) => { missing = listener; return () => {}; }, allowDownload: parts.allowDownload },
clipboard: { copyFile: async () => true, clearFiles: parts.clearFiles },
windows: { sessionReady: parts.sessionReady, setRestoreEnabled: parts.setRestoreEnabled },
theme: { set: parts.setTheme, system: async () => ({ dark: false, highContrast: false }) },
lifecycle: { onFlushRequest: (handler: () => Promise<unknown>) => { flush = handler; return () => {}; } },
};
window.bulwarkDesktop = bridge as unknown as BulwarkDesktopBridge;
return { ...parts, run: (id: string) => act(() => command!(id)), flush: () => flush!(), missing: (language: string) => act(() => missing!(language)) };
}
const original = useAuthStore.getState();
beforeEach(() => {
push.mockClear();
resolveExternalLink.mockClear();
resolveExternalLink.mockImplementation(async (ctx) => ctx.href);
requestInternalMailto.mockClear();
requestInternalMailto.mockReturnValue(true);
allowed.dictionary = true;
signOut.full.length = 0;
signOut.before.length = 0;
resetDesktopLanguages();
canPrint.mockReturnValue(true);
canDownload.mockReturnValue(true);
localStorage.clear();
resetDesktopPrefsCache();
useAuthStore.setState({ isAuthenticated: true, getAllConnectedClients: () => new Map() } as never);
});
afterEach(() => {
cleanup();
window.bulwarkDesktop = undefined;
resetDesktopCommands();
useAuthStore.setState(original);
window.history.replaceState(null, '', '/');
});
describe('DesktopHost', () => {
it('does nothing in a browser', () => {
expect(() => render(<DesktopHost />)).not.toThrow();
});
it('runs a command from what\'s on the page first', () => {
const shell = fakeBridge();
render(<DesktopHost />);
const onPage = vi.fn();
registerDesktopSurface({ handlers: { 'go.inbox': onPage }, state: {} });
shell.run('go.inbox');
expect(onPage).toHaveBeenCalledTimes(1);
expect(push).not.toHaveBeenCalled();
});
it('with nothing there, the commands that work anywhere still do', () => {
const shell = fakeBridge();
render(<DesktopHost />);
shell.run('go.calendar');
expect(push).toHaveBeenLastCalledWith('/calendar');
shell.run('go.contacts');
expect(push).toHaveBeenLastCalledWith('/contacts');
});
it('a link to a folder goes through the router, encoded', () => {
const shell = fakeBridge();
render(<DesktopHost />);
shell.run('link.folder:sent');
expect(push).toHaveBeenLastCalledWith('/mail/folder/sent');
});
it('an unknown command does nothing', () => {
const shell = fakeBridge();
render(<DesktopHost />);
expect(() => shell.run('nonsense')).not.toThrow();
expect(push).not.toHaveBeenCalled();
});
it('tells the shell what the organisation allows', async () => {
canPrint.mockReturnValue(false);
canDownload.mockReturnValue(false);
const shell = fakeBridge();
render(<DesktopHost />);
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'file.print': false, 'file.savePdf': false, 'policy.download': false });
});
it('signed in: it says so once, for windows from last time and waiting links', async () => {
const shell = fakeBridge();
render(<DesktopHost />);
await waitFor(() => expect(shell.sessionReady).toHaveBeenCalledTimes(1));
});
it('not signed in, or in a pop-out with a borrowed session: it doesn\'t', async () => {
const shell = fakeBridge();
useAuthStore.setState({ isAuthenticated: false } as never);
const first = render(<DesktopHost />);
await act(async () => {});
expect(shell.sessionReady).not.toHaveBeenCalled();
first.unmount();
useAuthStore.setState({ isAuthenticated: true } as never);
window.history.replaceState(null, '', '/popout/compose');
render(<DesktopHost />);
await act(async () => {});
expect(shell.sessionReady).not.toHaveBeenCalled();
});
it('passes the restore setting on', async () => {
const shell = fakeBridge();
render(<DesktopHost />);
await waitFor(() => expect(shell.setRestoreEnabled).toHaveBeenLastCalledWith(true));
act(() => setDesktopPref('restoreWindows', false));
await waitFor(() => expect(shell.setRestoreEnabled).toHaveBeenLastCalledWith(false));
});
it('says which languages are in use', async () => {
const shell = fakeBridge();
act(() => useLocaleStore.setState({ locale: 'de' }));
render(<DesktopHost />);
await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled());
expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'de' });
});
describe('Open Link in a native menu', () => {
const openLink = (url: string) => `link.open:${JSON.stringify({ url, text: 'click' })}`;
it('goes past the same check as a click on it', async () => {
const shell = fakeBridge();
render(<DesktopHost />);
shell.run(openLink('https://example.org/a'));
await waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://example.org/a'));
expect(resolveExternalLink).toHaveBeenCalledWith(expect.objectContaining({ href: 'https://example.org/a', text: 'click' }));
});
it('a plugin can stop it', async () => {
resolveExternalLink.mockResolvedValue(null);
const shell = fakeBridge();
render(<DesktopHost />);
shell.run(openLink('https://example.org/a'));
await waitFor(() => expect(resolveExternalLink).toHaveBeenCalled());
await act(async () => {});
expect(shell.openExternal).not.toHaveBeenCalled();
});
it('a plugin can send it elsewhere', async () => {
resolveExternalLink.mockResolvedValue('https://safe.example.org/');
const shell = fakeBridge();
render(<DesktopHost />);
shell.run(openLink('https://example.org/a'));
await waitFor(() => expect(shell.openExternal).toHaveBeenCalledWith('https://safe.example.org/'));
});
it('a mail address goes to the app\'s composer', async () => {
const shell = fakeBridge();
render(<DesktopHost />);
shell.run(openLink('mailto:a@example.org'));
await waitFor(() => expect(requestInternalMailto).toHaveBeenCalledWith('mailto:a@example.org'));
expect(shell.openExternal).not.toHaveBeenCalled();
});
it('tells the shell it has this path, so an older page keeps the direct way', async () => {
const shell = fakeBridge();
render(<DesktopHost />);
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
expect(shell.setState.mock.lastCall![0]).toMatchObject({ 'link.check': true });
});
});
it('hands the shell its menus in the interface language', async () => {
const shell = fakeBridge();
act(() => useLocaleStore.setState({ locale: 'fr' }));
render(<DesktopHost />);
await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled());
const { labels } = shell.setLanguages.mock.lastCall![0];
// The test's next-intl gives the key back.
expect(labels.menuFile).toBe('desktopUi.menu.file');
expect(labels.menuReply).toBe('context_menu.reply');
expect(labels.unsentMany).toBe('desktopUi.closing.unsent_many');
});
describe('a language with no checker in the OS', () => {
afterEach(() => resetComposeFlushRegistry());
const toasts = () => useToastStore.getState().toasts;
it('the composer asks once, and only a yes opens the downloads', async () => {
useToastStore.setState({ toasts: [] });
const shell = fakeBridge();
render(<DesktopHost />);
await act(async () => {});
shell.missing('nl-NL');
// No composer open: nobody is writing yet.
expect(toasts()).toHaveLength(0);
const stop = registerComposeFlush(async () => {});
await waitFor(() => expect(toasts()).toHaveLength(1));
expect(shell.allowDownload).not.toHaveBeenCalled();
act(() => toasts()[0].action!.onClick());
expect(shell.allowDownload).toHaveBeenCalledTimes(1);
expect(readDesktopPrefs().dictionaryDownload).toBe('yes');
stop();
});
it('never a second time', async () => {
useToastStore.setState({ toasts: [] });
setDesktopPref('dictionaryDownload', 'no');
const shell = fakeBridge();
render(<DesktopHost />);
await act(async () => {});
const stop = registerComposeFlush(async () => {});
shell.missing('nl-NL');
await act(async () => {});
expect(toasts()).toHaveLength(0);
stop();
});
it('never in no-third-party mode, even after an old yes', async () => {
useToastStore.setState({ toasts: [] });
allowed.dictionary = false;
setDesktopPref('dictionaryDownload', 'yes');
const first = fakeBridge();
const view = render(<DesktopHost />);
await act(async () => {});
expect(first.allowDownload).not.toHaveBeenCalled();
view.unmount();
setDesktopPref('dictionaryDownload', 'ask');
const shell = fakeBridge();
render(<DesktopHost />);
await act(async () => {});
const stop = registerComposeFlush(async () => {});
shell.missing('nl-NL');
await act(async () => {});
expect(toasts()).toHaveLength(0);
stop();
});
it('a yes from before opens them at the next start', async () => {
setDesktopPref('dictionaryDownload', 'yes');
const shell = fakeBridge();
render(<DesktopHost />);
await act(async () => {});
expect(shell.allowDownload).toHaveBeenCalledTimes(1);
});
});
it('signing out takes the files copied to the clipboard with it', async () => {
const shell = fakeBridge();
render(<DesktopHost />);
await act(async () => {});
signOut.full[0]();
expect(shell.clearFiles).toHaveBeenCalledTimes(1);
await signOut.before[0]();
expect(shell.clearFiles).toHaveBeenCalledTimes(2);
});
it('a quit is told how much mail is waiting', async () => {
const shell = fakeBridge();
render(<DesktopHost />);
act(() => useOfflineStore.setState({ outbox: [{ id: 1 }, { id: 2 }] } as never));
await expect(shell.flush()).resolves.toEqual({ unsent: 2 });
act(() => useOfflineStore.setState({ outbox: [] } as never));
});
});
+226
View File
@@ -0,0 +1,226 @@
"use client";
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { useStrictestDevices } from "@/hooks/use-device-policy";
import { useRouter } from "@/i18n/navigation";
import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
import { onComposerOpened, composerIsOpen } from "@/lib/compose-flush-registry";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { runDesktopCommand, setDesktopBaseState } from "@/lib/desktop/commands";
import { dictionaryDownloadAllowed } from "@/lib/desktop/dictionary-download";
import { noteComposerLanguage, setDesktopInterfaceLanguage, shellLabels } from "@/lib/desktop/languages";
import { openLinkFromNativeMenu } from "@/lib/desktop/open-link";
import { readDesktopPrefs, setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences";
import { applySystemAccent } from "@/lib/desktop/system-accent";
import type { DesktopSystemTheme } from "@/lib/desktop/types";
import { canDownloadAnywhere, canPrintAnywhere } from "@/lib/device-policy";
import { useOfflineStore } from "@/lib/offline/state";
import { openNewComposerWindow } from "@/lib/popout/open";
import { isPopoutWindow } from "@/lib/popout/window-role";
import { onBeforeSignOut, onFullSignOut } from "@/lib/sign-out-cleanup";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useLocaleStore } from "@/stores/locale-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useThemeStore } from "@/stores/theme-store";
import { toast } from "@/stores/toast-store";
/**
* The webmail's side of what every desktop window needs: runs menu commands nothing
* else on the page took, tells the shell the language and the device policy, and
* says how much mail is waiting when the app quits. Renders nothing and does nothing
* in a browser, where there's no bridge.
*/
export function DesktopHost() {
const router = useRouter();
const routerRef = useRef(router);
routerRef.current = router;
const t = useTranslations();
const tRef = useRef(t);
tRef.current = t;
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts);
const writingLanguage = useSettingsStore((s) => s.writingLanguage);
const uiLocale = useLocaleStore((s) => s.locale);
const strictest = useStrictestDevices();
const { restoreWindows } = useDesktopPrefs();
const theme = useThemeStore((s) => s.theme);
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const activeThemeId = useThemeStore((s) => s.activeThemeId);
// Commands no surface on the page answers: those work from anywhere in the app.
useEffect(() => {
const menu = getDesktopBridge()?.menu;
if (!menu?.onCommand) return;
const toMail = (command: AppCommand) => {
if (requestAppCommand(command)) return;
parkAppCommand(command);
routerRef.current.push("/");
};
const fallback: Record<string, () => void> = {
"file.newMessage": () => {
if (isPopoutWindow()) openNewComposerWindow(useAuthStore.getState().activeAccountId);
else toMail("compose");
},
"edit.search": () => toMail("search"),
"go.inbox": () => routerRef.current.push("/"),
"go.mail": () => routerRef.current.push("/"),
"go.calendar": () => routerRef.current.push("/calendar"),
"go.contacts": () => routerRef.current.push("/contacts"),
"go.files": () => routerRef.current.push("/files"),
"help.shortcuts": () => openKeyboardShortcuts(),
};
return menu.onCommand((id) => {
// A bulwark:// link to a folder.
if (id.startsWith("link.folder:")) {
routerRef.current.push(`/mail/folder/${encodeURIComponent(id.slice("link.folder:".length))}`);
return;
}
// Open Link in a native menu.
if (id.startsWith("link.open:")) {
try {
const { url, text } = JSON.parse(id.slice("link.open:".length)) as { url: string; text: string };
void openLinkFromNativeMenu(url, text, useEmailStore.getState().selectedEmail?.id);
} catch {
// Only the shell sends this, and it sends JSON.
}
return;
}
if (!runDesktopCommand(id)) fallback[id]?.();
});
}, []);
// What's on offer anywhere, and what the organisation allows.
useEffect(() => {
if (!getDesktopBridge()?.menu) return;
const canPrint = canPrintAnywhere();
const signedIn = isAuthenticated;
const popout = isPopoutWindow();
setDesktopBaseState({
"link.check": true,
"shortcuts.singleKeys": singleKeys,
"policy.download": canDownloadAnywhere(),
"file.print": canPrint,
"file.savePdf": canPrint,
"file.newMessage": signedIn,
"edit.search": signedIn && !popout,
"go.inbox": signedIn,
"go.mail": signedIn,
"go.calendar": signedIn,
"go.contacts": signedIn,
"go.files": signedIn,
"help.shortcuts": true,
});
}, [isAuthenticated, singleKeys, strictest]);
// Menus and spellcheck follow the interface.
useEffect(() => {
const ui = uiLocale || document.documentElement.lang;
if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as (key: string) => string));
}, [uiLocale]);
// What the user writes in, whichever composer asks for it too.
useEffect(() => {
noteComposerLanguage("settings", writingLanguage && writingLanguage !== "auto" ? writingLanguage : null);
}, [writingLanguage]);
// A language with no checker in the OS needs a dictionary from Google: the composer
// asks once, and never in no-third-party mode. Until a yes, the shell has nowhere to
// fetch from at all.
useEffect(() => {
const spellcheck = getDesktopBridge()?.spellcheck;
if (!spellcheck) return;
if (readDesktopPrefs().dictionaryDownload === "yes" && dictionaryDownloadAllowed()) spellcheck.allowDownload();
const waiting = new Set<string>();
const offer = () => {
const [language] = waiting;
if (!language || !composerIsOpen() || !dictionaryDownloadAllowed()) return;
if (readDesktopPrefs().dictionaryDownload !== "ask") return;
// Asked once, whatever comes of it.
setDesktopPref("dictionaryDownload", "no");
const translate = tRef.current;
let name = language;
try {
name = new Intl.DisplayNames([document.documentElement.lang || "en"], { type: "language" }).of(language) ?? language;
} catch {
// The tag itself will do.
}
toast.info(translate("desktopUi.spellcheck.offer", { language: name }), {
duration: 0,
action: {
label: translate("desktopUi.spellcheck.download"),
onClick: () => {
setDesktopPref("dictionaryDownload", "yes");
if (dictionaryDownloadAllowed()) spellcheck.allowDownload();
},
},
});
};
const heard = (language: string) => {
waiting.add(language);
offer();
};
void spellcheck.missing().then((list) => list.forEach(heard));
const stopMissing = spellcheck.onMissing(heard);
const stopComposer = onComposerOpened(offer);
return () => {
stopMissing();
stopComposer();
};
}, []);
// Signed out: nothing of that account stays on the file clipboard.
useEffect(() => {
const clipboard = getDesktopBridge()?.clipboard;
if (!clipboard?.clearFiles) return;
const clear = () => clipboard.clearFiles?.();
onFullSignOut(clear);
onBeforeSignOut(async () => clear());
}, []);
// The windows' behaviour lives here, the shell only needs its copy.
useEffect(() => {
getDesktopBridge()?.windows?.setRestoreEnabled?.(restoreWindows);
}, [restoreWindows]);
// Signed in: this window has a session to lend, for pop-outs from last time and links.
useEffect(() => {
if (!isAuthenticated || isPopoutWindow()) return;
getDesktopBridge()?.windows?.sessionReady?.();
}, [isAuthenticated]);
// A quit asks for this: mail that hasn't left is worth a question.
useEffect(() => {
const lifecycle = getDesktopBridge()?.lifecycle;
if (!lifecycle || isPopoutWindow()) return;
return lifecycle.onFlushRequest(async () => ({ unsent: useOfflineStore.getState().outbox.length }));
}, []);
// The OS accent colour, while the theme follows the OS.
useEffect(() => {
const bridge = getDesktopBridge()?.theme;
if (!bridge) return;
const followsSystem = theme === "system" && !activeThemeId;
const apply = (system: DesktopSystemTheme) => {
applySystemAccent({
accent: system.accent,
highContrast: system.highContrast,
dark: document.documentElement.classList.contains("dark"),
followsSystem,
});
};
let cancelled = false;
void bridge.system?.().then((system) => {
if (!cancelled) apply(system);
});
const stop = bridge.onSystemChange?.(apply);
return () => {
cancelled = true;
stop?.();
};
}, [theme, resolvedTheme, activeThemeId]);
return null;
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useEffect, useRef } from "react";
import { useLocale, useTranslations } from "next-intl";
import { useArchiveActionEnabled } from "@/lib/archive-gate";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { isAppLocked } from "@/lib/desktop/app-lock-state";
import { contentHiddenFor, startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications";
import { startOsSummary } from "@/lib/desktop/os-summary";
import { startPowerWatch } from "@/lib/desktop/power";
import { startWindowFiles } from "@/lib/desktop/window-files";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
/**
* The main window's end of the desktop app's OS integration: new mail as native
* notifications, what the tray and jump list show, waking up after sleep, and files
* dropped or pasted on the window. Renders nothing, and does nothing in a browser.
* Lives inside MainWindowOnly, so pop-outs don't do any of it a second time.
*/
export function DesktopOsHost() {
const t = useTranslations("desktopOs");
const tNotify = useTranslations("notifications");
const tPush = useTranslations("pwaNotify");
const locale = useLocale();
const archiveEnabled = useArchiveActionEnabled();
const clientKey = useAccountStore((s) => s.accounts.map((a) => a.id).join("\n"));
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const mailRef = useRef<MailNotifications | null>(null);
const summaryRef = useRef<{ refresh: () => void } | null>(null);
const localeRef = useRef(locale);
localeRef.current = locale;
const labels: MailNotificationLabels = {
newMail: t("new_mail"),
newMailCount: (count) => tNotify("new_mail_count", { count }),
noSubject: t("no_subject"),
archive: archiveEnabled ? tPush("action_archive") : null,
markRead: tPush("action_mark_read"),
delete: t("action_delete"),
reply: tPush("action_reply"),
replyPlaceholder: tPush("reply_placeholder"),
};
const labelsRef = useRef(labels);
labelsRef.current = labels;
useEffect(() => {
const os = getDesktopBridge()?.os;
if (!os) return;
// Main shows no content anywhere until this page has said it may.
os.setLocked(isAppLocked());
const mail = startMailNotifications(labelsRef.current);
mailRef.current = mail;
const summary = startOsSummary(() => localeRef.current);
summaryRef.current = summary;
const stopPower = startPowerWatch();
const stopFiles = startWindowFiles();
return () => {
mail.stop();
summary.stop();
stopPower();
stopFiles();
mailRef.current = null;
summaryRef.current = null;
};
}, []);
useEffect(() => {
mailRef.current?.setLabels(labels);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [locale, archiveEnabled]);
// Every login needs a state to work from before its first push.
useEffect(() => {
const mail = mailRef.current;
if (!mail || !isAuthenticated) return;
const auth = useAuthStore.getState();
const clients = new Set<IJMAPClient>(useAccountStore.getState().accounts.flatMap((account) => auth.getClientForAccount(account.id) ?? []));
if (auth.client) clients.add(auth.client);
for (const client of clients) mail.seed(client);
summaryRef.current?.refresh();
}, [isAuthenticated, clientKey]);
return null;
}
@@ -129,6 +129,8 @@ describe('No export', () => {
await waitFor(() => expect(discard).toHaveBeenCalledWith(true));
expect(controller.signal.aborted).toBe(true);
expect(useMailTransferStore.getState().job).toBeNull();
// The dialog just went: this is all that says why.
expect(toast.info).toHaveBeenCalledWith('export_blocked');
});
});
+3 -1
View File
@@ -4,7 +4,7 @@ import { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl';
import { useDevicePolicy, useStrictestDevices } from '@/hooks/use-device-policy';
import { DOWNLOAD_BLOCKED_EVENT, type DownloadKind } from '@/lib/download-guard';
import { DOWNLOAD_BLOCKED_EVENT, downloadAllowed, type DownloadKind } from '@/lib/download-guard';
// Installs the download guard (lib/download-guard.ts) for every page.
import '@/lib/device-policy';
import { useMailTransferStore } from '@/stores/mail-transfer-store';
@@ -48,6 +48,8 @@ export function DevicePolicyHost() {
if (store.job?.kind !== 'export') return;
const job = store.job;
store.cancelJob('policy');
// This closes the dialog too, which would otherwise be gone with no reason given.
downloadAllowed('export', null);
// The private file goes at once, once the job has stopped: nothing may download it any more.
const discard = () => {
if (useMailTransferStore.getState().job?.controller === job.controller) useMailTransferStore.getState().close({ now: true });
@@ -1,5 +1,5 @@
import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
import { beforeEach, describe, it, expect, vi } from 'vitest';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
@@ -235,6 +235,39 @@ describe('composer close guard with a follow-up', () => {
expect(afterClose).not.toHaveBeenCalled();
});
describe('in the desktop app', () => {
afterEach(() => {
window.bulwarkDesktop = undefined;
});
const nativeBridge = (answer: number) => {
const confirm = vi.fn(async () => answer);
window.bulwarkDesktop = { version: 1, platform: 'win32', dialogs: { confirm } } as never;
return confirm;
};
it('asks through the OS, and shows no dialog of its own', async () => {
const confirm = nativeBridge(2);
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
act(() => requestCloseRef.current!(afterClose));
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', buttons: ['save', 'discard', 'cancel'], defaultId: 0 }]);
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
// The last button is Cancel: nothing happens.
await act(async () => {});
expect(onClose).not.toHaveBeenCalled();
expect(afterClose).not.toHaveBeenCalled();
});
it('Discard discards, and the follow-up runs', async () => {
nativeBridge(1);
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
act(() => requestCloseRef.current!(afterClose));
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
});
});
it('runs the follow-up after discarding', async () => {
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
@@ -1,5 +1,5 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, it, expect, vi } from 'vitest';
import { ReaderAttachments, splitFileName, type ReaderAttachmentItem } from '../reader-attachments';
// The reader's attachment bar: equal tiles that keep the extension in view,
@@ -24,6 +24,47 @@ function renderBar(attachments: ReaderAttachmentItem[], overrides: Partial<Param
return props;
}
describe('the right-click menu in the desktop app', () => {
afterEach(() => {
window.bulwarkDesktop = undefined;
cleanup();
});
const openMenu = async (popupAnswer: (items: { id?: string; label?: string }[]) => string | null, overrides: Partial<Parameters<typeof ReaderAttachments>[0]> = {}) => {
const popup = vi.fn(async (items: { id?: string; label?: string }[]) => popupAnswer(items));
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as never;
const props = renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn(), ...overrides });
fireEvent.contextMenu(await screen.findByTestId('attachment'), { clientX: 5, clientY: 5 });
await waitFor(() => expect(popup).toHaveBeenCalled());
return { props, popup };
};
it('has Open, Save as and Copy, natively', async () => {
const { popup } = await openMenu(() => null);
expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open', 'save_as', 'copy']);
});
it.each([['open', 'onOpen'], ['save_as', 'onDownload'], ['copy', 'onCopyFile']] as const)('%s runs the action', async (label, handler) => {
const { props } = await openMenu((items) => items.find((item) => item.label === label)!.id!);
await waitFor(() => expect((props as unknown as Record<string, ReturnType<typeof vi.fn>>)[handler]).toHaveBeenCalledTimes(1));
expect((props as unknown as Record<string, ReturnType<typeof vi.fn>>)[handler].mock.calls[0][0]).toMatchObject({ id: '1' });
});
it('with downloads off there\'s nothing to save or copy', async () => {
const { popup } = await openMenu(() => null, { downloadsOff: true, opensPreview: () => true });
expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open']);
});
it('a browser keeps its own menu', async () => {
window.bulwarkDesktop = undefined;
renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn() });
const event = fireEvent.contextMenu(await screen.findByTestId('attachment'));
// Not prevented: the browser's menu shows, and no HTML one opens.
expect(event).toBe(true);
expect(screen.queryByRole('menu')).toBeNull();
});
});
describe('splitFileName', () => {
it('splits off a short extension', () => {
expect(splitFileName('prototype-v3.png')).toEqual(['prototype-v3', '.png']);
@@ -75,6 +75,14 @@ describe('EmailViewer mark as read', () => {
await waitFor(() => expect(onMarkAsRead).toHaveBeenCalledWith('e1', true, { automatic: true }));
});
it('a message opened from a link stays unread until the user says', async () => {
useSettingsStore.setState({ markAsReadDelay: 0 });
const onMarkAsRead = vi.fn();
render(<EmailViewer email={unread} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onMarkAsRead={onMarkAsRead} noAutomaticActions />);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(onMarkAsRead).not.toHaveBeenCalled();
});
it("passes the toolbar button on as the user's own change", () => {
useSettingsStore.setState({ markAsReadDelay: -1 });
const onMarkAsRead = vi.fn();
@@ -271,6 +271,14 @@ describe('viewer send failures', () => {
useAuthStore.setState({ client: null, identities: [] } as never);
});
it('a message opened from a link sends none by itself', async () => {
render(<EmailViewer email={makeEmail({ headers: MDN_HEADERS })} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" noAutomaticActions />);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
expect(sendReadReceipt).not.toHaveBeenCalled();
});
it('tries once per opening, not once per refresh, when the quota is full on Stalwart 1.0', async () => {
const props = { mailboxes, selectedMailbox: 'inbox', currentMailboxRole: 'inbox' };
const { rerender } = render(<EmailViewer email={makeEmail({ headers: MDN_HEADERS })} {...props} />);
+66 -7
View File
@@ -168,9 +168,14 @@ import type { Editor } from "@tiptap/react";
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
import { fileStorage } from "@/lib/plugin-storage";
import { usePolicyStore } from "@/stores/policy-store";
import { registerComposeFlush } from "@/lib/compose-flush-registry";
import { isAppLockedNow } from "@/lib/app-lock/state";
import { registerComposeFlush, registerComposeSnapshot } from "@/lib/compose-flush-registry";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { noteComposerLanguage } from "@/lib/desktop/languages";
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";
/**
* Derives the text/plain alternative from the composer's HTML body, preserving
@@ -383,7 +388,7 @@ interface EmailComposerProps {
/** The state as the window unloads, for a reload to start from. */
onUnloadState?: (data: ComposerDraftData) => void;
/** A save reached the server, for other windows showing the Drafts folder. */
onDraftSaved?: () => void;
onDraftSaved?: (draftId: string) => void;
/**
* Hands a draft to another window to save as this one closes: a request
* started now wouldn't survive. False when that can't be done safely.
@@ -1943,11 +1948,21 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// A composer that comes back with the app: a draft it opened on counts from the start.
// Gone with the composer: sent, discarded or closed.
useEffect(() => {
if (draftIdRef.current) noteOpenDraft(effectiveSessionKey, { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current ?? useAuthStore.getState().activeAccountId, ownerAccountId: draftAccountIdRef.current });
return () => noteOpenDraft(effectiveSessionKey, null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveSessionKey]);
// Auto-save draft to server on page close (best-effort)
const ownWindowRef = useRef(ownWindow);
ownWindowRef.current = ownWindow;
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
// The lock saved what it could and is on its way to the lock screen: no prompt, and no copy of the text.
if (isAppLockedNow()) return;
const own = ownWindowRef.current;
if (!own) {
if (isDirtyRef.current) {
@@ -2698,6 +2713,12 @@ export function EmailComposer({
const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar');
// The plugin side panels fold when the composer gets too narrow for them.
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.
useEffect(() => setComposerFileTarget(
(files) => void addFiles(files),
() => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0,
), [addFiles, composerRootEl]);
// The id plugins know this composer by (registered below, with the handle).
const [pluginComposerId, setPluginComposerId] = useState<string | null>(null);
const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId);
@@ -3017,6 +3038,8 @@ export function EmailComposer({
draftIdRef.current = savedDraftId;
draftAccountIdRef.current = draftAccountId;
draftLocalAccountIdRef.current = savingLogin;
// Before a save's promise settles: quitting waits for it, then keeps this list.
noteOpenDraft(effectiveSessionKey, { draftId: savedDraftId, localAccountId: savingLogin ?? useAuthStore.getState().activeAccountId, ownerAccountId: draftAccountId });
setDraftId(savedDraftId);
setDraftAccountId(draftAccountId);
setDraftLocalAccountId(savingLogin);
@@ -3097,7 +3120,7 @@ export function EmailComposer({
// Only the save the user asked for is read out: autosave would say it
// every minute, and the save before sending is followed by "Sent".
if (opts.announce && id && !saveFailedRef.current) announceStatus(t('draft_saved'));
if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.();
if (id && !saveFailedRef.current) ownWindowRef.current?.onDraftSaved?.(id);
return id;
})();
inflightSaveRef.current = promise;
@@ -3130,6 +3153,15 @@ export function EmailComposer({
if (saveFailedRef.current) throw new Error('The draft was not saved');
}), []);
// Where this composer's text is, for the app lock.
useEffect(() => registerComposeSnapshot({
ownWindow: !!ownWindowRef.current,
state: () => (sentRef.current || uneditedRef.current ? null : snapshotStateRef.current()),
draft: () => (draftIdRef.current && !sentRef.current
? { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current, ownerAccountId: draftAccountIdRef.current }
: null),
}), []);
// The draft as plugins see it: onDraftChange, the frames above and below
// the body, and clicks on plugin entries in the Send menu.
const draftSnapshot = () => ({
@@ -3360,6 +3392,11 @@ export function EmailComposer({
const uiLocale = useLocale();
const writingLanguage = useSettingsStore((s) => s.writingLanguage);
const textLang = writingLanguage && writingLanguage !== 'auto' ? writingLanguage : uiLocale;
// The desktop app's spellcheck follows what's being written.
useEffect(() => {
noteComposerLanguage(effectiveSessionKey, textLang);
return () => noteComposerLanguage(effectiveSessionKey, null);
}, [effectiveSessionKey, textLang]);
const proofreading = useProofreading({
composerId: pluginComposerId,
sessionKey: effectiveSessionKey,
@@ -4433,6 +4470,29 @@ export function EmailComposer({
emitClose();
};
/** The question before closing or discarding: the OS's own in the desktop app. */
const openCloseDialog = (kind: 'discard' | 'close') => {
const dialogs = getDesktopBridge()?.dialogs;
if (!dialogs) {
setCloseDialogKind(kind);
setShowCloseDialog(true);
return;
}
const discarding = kind === 'discard';
const buttons = discarding ? [t('discard'), t('cancel')] : [tCommon('save'), t('discard'), t('cancel')];
void dialogs.confirm({
type: 'warning',
message: discarding ? t('discard_draft_title') : t('close_draft_title'),
detail: discarding ? t('discard_draft_message') : t('close_draft_message'),
buttons,
defaultId: discarding ? 1 : 0,
}).then((choice) => {
if (buttons[choice] === t('discard')) handleDiscardAndClose();
else if (!discarding && choice === 0) void handleSaveDraftAndClose();
else afterCloseRef.current = null;
});
};
// Discard deletes the draft, also one that has no unsaved changes, such as
// a scheduled message reopened with "Cancel and edit". It used to share the
// X button's path, which closed such a composer and kept the draft.
@@ -4444,8 +4504,7 @@ export function EmailComposer({
return;
}
afterCloseRef.current = null;
setCloseDialogKind('discard');
setShowCloseDialog(true);
openCloseDialog('discard');
};
const handleClose = (afterClose?: () => void) => {
@@ -4457,8 +4516,7 @@ export function EmailComposer({
&& !saveFailedRef.current
&& !attachmentsRef.current.some(att => att.uploading || att.error);
if (isDirtyRef.current && !savedAlready) {
setCloseDialogKind('close');
setShowCloseDialog(true);
openCloseDialog('close');
} else {
cleanClose();
}
@@ -4744,6 +4802,7 @@ export function EmailComposer({
className="-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11"
label={tCommon('close')}
data-testid="composer-close"
data-own-window-close={ownWindow ? "" : undefined}
>
<X className="w-5 h-5 md:w-4 md:h-4" />
</IconButton>
+35 -3
View File
@@ -160,6 +160,7 @@ import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
import { openPreviewInNewWindow } from "@/lib/standalone-display";
import { downloadAllowed, saveBlobAs } from "@/lib/download-guard";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { canPickFolder, createUniqueNamer, isPickerCancelled, pickFolder, saveFilesToFolder, type FolderHandle } from "@/lib/attachments/save-to-folder";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { Watermark, useWatermarkText } from "@/components/device/watermark";
@@ -290,6 +291,11 @@ interface EmailViewerProps {
onToggleFullscreen?: () => void;
/** Opens this message in its own window; left out where that isn't on offer. */
onOpenInWindow?: () => void;
/**
* Opened by something outside the app (a bulwark:// link), not by the user in it.
* It shows the message and does nothing on its own: no mark as read, no read receipt.
*/
noAutomaticActions?: boolean;
isFullscreen?: boolean;
currentUserEmail?: string;
currentUserName?: string;
@@ -678,6 +684,7 @@ interface DraggableAttachmentChipProps {
onPointerEnter: () => void;
onDragStart: (e: React.DragEvent<HTMLDivElement>) => void;
onDragEnd: (e: React.DragEvent<HTMLDivElement>) => void;
onCopyAsFile?: () => Promise<boolean>;
}) => React.ReactNode;
}
@@ -685,6 +692,19 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl
const source = useMemo<AttachmentDragSource>(() => ({
name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream',
// A decrypted attachment doesn't go through the app's temporary folder or the clipboard: it
// stays with the browser's drag, which only writes it where the user drops it.
...(attachment.decryptedAttachment ? {} : {
getBlob: async () => {
if (attachment.blobId && client) {
return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null);
}
const bytes = attachment.tnefData;
if (!bytes || bytes.byteLength === 0) return null;
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
return new Blob([buffer], { type: inertBlobType(attachment.type) });
},
}),
getBlobUrl: async () => {
if (attachment.blobId && client) {
try {
@@ -1324,6 +1344,7 @@ export function EmailViewer({
onCompose,
onToggleFullscreen,
onOpenInWindow,
noAutomaticActions = false,
isFullscreen = false,
currentUserEmail,
currentUserName,
@@ -2203,7 +2224,7 @@ export function EmailViewer({
useEffect(() => {
// Mark as read when email is viewed, respecting the delay setting
if (!email || !onMarkAsRead) {
if (!email || !onMarkAsRead || noAutomaticActions) {
return;
}
@@ -3122,6 +3143,8 @@ export function EmailViewer({
// What the attachment bar and the attachment cards share.
const attachmentCardsRef = useRef<HTMLDivElement>(null);
const desktopClipboard = getDesktopBridge()?.clipboard;
const tDesktopUi = useTranslations('desktopUi.attachment');
const readerAttachmentProps = {
attachments: effectiveAttachments,
thumbUrls: imageThumbUrls,
@@ -3130,6 +3153,13 @@ export function EmailViewer({
onDownload: handleEffectiveAttachmentDownload,
onDownloadAll: handleDownloadAllAttachments,
onSaveAllToFolder: canPickFolder() ? () => void handleSaveAllToFolder() : undefined,
onCopyFile: desktopClipboard && blobClient ? (attachment: EffectiveAttachment) => {
if (!attachment.blobId || !downloadAllowed('attachment', email)) return;
void blobClient.fetchBlobArrayBuffer(attachment.blobId, undefined, undefined, blobAccountId)
.then((data) => desktopClipboard.copyFile(resolveAttachmentName(attachment), data))
.then((copied) => { if (!copied) toast.error(tDesktopUi('copy_failed')); })
.catch(() => toast.error(tDesktopUi('copy_failed')));
} : undefined,
downloadingAll: isDownloadingAll,
downloadsOff,
// Save to Files would get around the policy: Files downloads are off too, but the copy would stay.
@@ -3951,7 +3981,7 @@ ${printWatermark ? printWatermark.html : ''}
if (autoMdnRef.current !== email?.id) autoMdnRef.current = null;
}, [email?.id]);
useEffect(() => {
if (readReceiptResponse !== 'always') return;
if (readReceiptResponse !== 'always' || noAutomaticActions) return;
if (!shouldOfferReadReceipt || !email) return;
if (autoMdnRef.current === email.id) return;
autoMdnRef.current = email.id;
@@ -4108,7 +4138,9 @@ ${printWatermark ? printWatermark.html : ''}
// in a split pane - the empty reading pane should always say what it is
// and offer a way forward.
return shell(
<div className="flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
<div className="relative flex-1 min-w-0 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50">
{/* An empty reading pane's top is still the window's top, to drag by. */}
<div aria-hidden="true" data-window-top-row="" className="absolute inset-x-0 top-0 h-14" />
<div className="text-center p-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
<Mail className="w-10 h-10 text-muted-foreground" />
+54 -3
View File
@@ -1,13 +1,17 @@
"use client";
import { useState, type DragEvent, type ReactNode } from "react";
import { useEffect, useState, type DragEvent, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronUp, Download, FolderInput, Loader2, Paperclip } from "@/components/icons";
import { ChevronDown, ChevronUp, Copy, Download, FolderInput, Loader2, Paperclip } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { nativeMenusAvailable } from "@/lib/desktop/native-menu";
import { IconButton } from "@/components/ui/icon-button";
import { getAttachmentDisplayName } from "@/lib/attachment-name";
import { cn, formatFileSize } from "@/lib/utils";
import { noMediaSavingProps } from "@/lib/device-frame";
import { toast } from "@/stores/toast-store";
import { attachmentIcon } from "./attachment-chips";
export interface ReaderAttachmentItem {
@@ -26,6 +30,8 @@ export interface AttachmentDragProps {
onPointerEnter: () => void;
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
/** The desktop app only. */
onCopyAsFile?: () => Promise<boolean>;
}
/** Past this many tiles the bar shows one row less and a "Show N more" tile. */
@@ -47,6 +53,11 @@ interface ReaderAttachmentsProps<T extends ReaderAttachmentItem> {
layout?: ReaderAttachmentLayout;
/** Object URLs of image attachments, keyed by attachment id. */
thumbUrls: Record<string, string>;
/**
* Copies the file to the clipboard. Comes with the right-click menu the desktop app
* has on an attachment; a browser has its own menu there and never gets this one.
*/
onCopyFile?: (attachment: T) => void;
/** Whether a click on the attachment previews it instead of downloading it. */
opensPreview: (attachment: T) => boolean;
onOpen: (attachment: T) => void;
@@ -94,6 +105,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
onDownload,
onDownloadAll,
onSaveAllToFolder,
onCopyFile,
downloadingAll,
downloadsOff = false,
onSaveToFiles,
@@ -105,6 +117,11 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
const tFiles = useTranslations("files");
const tDevice = useTranslations("device_policy");
const tWindows = useTranslations("pwaWindows");
const tDesktop = useTranslations("desktopUi.attachment");
const menu = useContextMenu<T>();
const [desktopMenu, setDesktopMenu] = useState(false);
useEffect(() => setDesktopMenu(nativeMenusAvailable()), []);
const menuTarget = menu.contextMenu.data;
// Opens nothing: no preview, and downloads are off.
const locked = (attachment: T) => downloadsOff && !opensPreview(attachment);
const openLabel = (attachment: T, name: string) =>
@@ -159,6 +176,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
</IconButton>
)}
{pluginMenu?.(attachment)}
{!downloadsOff && drag.onCopyAsFile && <CopyAsFileButton copy={drag.onCopyAsFile} />}
{!downloadsOff && (
<IconButton
label={t("download_named", { name })}
@@ -229,6 +247,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
</IconButton>
)}
{pluginMenu?.(attachment)}
{!downloadsOff && drag.onCopyAsFile && <CopyAsFileButton copy={drag.onCopyAsFile} />}
{!downloadsOff && (
<IconButton label={t("download_named", { name })} title={t("download")} onClick={() => onDownload(attachment)}>
<Download className="h-4 w-4" />
@@ -292,7 +311,7 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
layout === "cards" ? "grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-3" : "grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2",
)}>
{shown.map((attachment) => (
<div key={attachment.id} className="flex min-w-0 flex-col gap-1">
<div key={attachment.id} className="flex min-w-0 flex-col gap-1" onContextMenu={desktopMenu ? (event) => menu.openContextMenu(event, attachment) : undefined}>
{renderItem
? renderItem(attachment, (drag) => draw(attachment, drag))
: draw(attachment, NO_DRAG)}
@@ -324,6 +343,19 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
</button>
)}
</div>
{desktopMenu && menuTarget && (
<ContextMenu ref={menu.menuRef} isOpen={menu.contextMenu.isOpen} position={menu.contextMenu.position} onClose={menu.closeContextMenu}>
{!locked(menuTarget) && (
<ContextMenuItem label={tDesktop("open")} onClick={() => { menu.closeContextMenu(); onOpen(menuTarget); }} />
)}
{!downloadsOff && (
<ContextMenuItem label={tDesktop("save_as")} onClick={() => { menu.closeContextMenu(); onDownload(menuTarget); }} />
)}
{!downloadsOff && onCopyFile && (
<ContextMenuItem label={tDesktop("copy")} onClick={() => { menu.closeContextMenu(); onCopyFile(menuTarget); }} />
)}
</ContextMenu>
)}
</section>
);
}
@@ -332,6 +364,25 @@ export function ReaderAttachments<T extends ReaderAttachmentItem>({
* The image itself when there is a thumbnail, else the file type's icon over
* its extension, in the type's colour.
*/
/** The desktop app's "Copy as file", for pasting the attachment into Explorer or Finder. */
function CopyAsFileButton({ copy }: { copy: () => Promise<boolean> }) {
const t = useTranslations("desktopOs");
return (
<IconButton
label={t("copy_as_file")}
className="text-muted-foreground hover:text-foreground"
onClick={() => {
void copy().then((ok) => {
if (ok) toast.success(t("copied"));
else toast.error(t("copy_failed"));
}, () => toast.error(t("copy_failed")));
}}
>
<Copy className="h-4 w-4" />
</IconButton>
);
}
function AttachmentThumb({ attachment, url, label }: { attachment: ReaderAttachmentItem; url?: string; label: string }) {
if (url) {
return <img src={url} alt="" loading="lazy" className="h-12 w-12 shrink-0 rounded-md bg-muted object-cover" />;
+1
View File
@@ -82,6 +82,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
{/* Full-screen under `viewport-fit=cover`: the bar pads itself out of
the iOS status bar (#936). The insets are physical, so pl/pr. */}
<div
data-window-top-row=""
className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+var(--app-top-inset))] pb-3 bg-background/90 backdrop-blur border-b border-border"
onClick={(e) => e.stopPropagation()}
>
@@ -0,0 +1,88 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// A refused account beside another that stays signed in: the switcher marks it,
// with the count and the date, and takes the user to its sign-in.
const push = vi.hoisted(() => vi.fn());
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push, replace: vi.fn() }),
usePathname: () => '/',
}));
const { AccountSwitcher } = await import('../account-switcher');
const { useAccountStore } = await import('@/stores/account-store');
const { useAuthStore } = await import('@/stores/auth-store');
const { useSettingsStore } = await import('@/stores/settings-store');
const { markLoginRefused, setRefusedCount, dropRefusedLogin } = await import('@/lib/devices/wipe-state');
const { useOfflineStore, markEvicted, clearEvicted } = await import('@/lib/offline/state');
const A = 'a@example.com';
const B = 'b@example.com';
function account(id: string) {
return { id, label: id, email: id, username: id, displayName: id.toUpperCase(), serverUrl: 'https://mail.example.com', avatarColor: '#336699', cookieSlot: 0, isConnected: true, isDefault: id === A };
}
function open(): void {
render(<AccountSwitcher variant="rail" />);
fireEvent.click(screen.getByTestId('account-switcher'));
}
beforeEach(() => {
push.mockClear();
localStorage.clear();
useAccountStore.setState({ accounts: [account(A)] } as never);
useAuthStore.setState({ activeAccountId: A } as never);
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
useOfflineStore.setState({ enabled: [A, B] });
markLoginRefused(B, { certain: true, seenBy: [A], label: B, username: B });
markEvicted(B);
});
describe('an account that needs signing in again', () => {
it('is marked, with its count and the date its data goes', () => {
setRefusedCount(B, 2);
open();
const row = screen.getByTestId('account-needs-sign-in');
expect(row.textContent).toContain(B);
expect(row.textContent).toContain('needs_sign_in');
expect(row.textContent).toContain('switcher_waiting');
});
it('says only the date when it couldn\'t count, or there\'s nothing unsent', () => {
setRefusedCount(B, 0);
open();
const row = screen.getByTestId('account-needs-sign-in');
expect(row.textContent).toContain('switcher_kept');
expect(row.textContent).not.toContain('switcher_waiting');
});
it('takes the user to its sign-in, with the name filled in', () => {
setRefusedCount(B, 2);
open();
fireEvent.click(screen.getByTestId('account-needs-sign-in'));
expect(push).toHaveBeenCalledWith(`/login?mode=add-account&username=${encodeURIComponent(B)}`);
});
it('shows the count as soon as it\'s known, with the menu open', () => {
open();
expect(screen.getByTestId('account-needs-sign-in').textContent).toContain('switcher_kept');
act(() => setRefusedCount(B, 3));
expect(screen.getByTestId('account-needs-sign-in').textContent).toContain('switcher_waiting');
});
it('isn\'t shown to an account that wasn\'t there', () => {
useAuthStore.setState({ activeAccountId: 'stranger@example.com' } as never);
useAccountStore.setState({ accounts: [account('stranger@example.com')] } as never);
open();
expect(screen.queryByTestId('account-needs-sign-in')).toBeNull();
});
it('is gone once it signed in again', () => {
clearEvicted(B);
open();
expect(screen.queryByTestId('account-needs-sign-in')).toBeNull();
dropRefusedLogin(B);
});
});
+30
View File
@@ -17,6 +17,7 @@ import { inboxUnread, totalInboxUnread } from "@/lib/account-inbox-unread";
import { isDocumentRTL } from "@/i18n/direction";
import { cn, formatFileSize } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation";
import { onRefusedLoginsChanged, siblingWaits, waitDate } from "@/lib/devices/wipe-state";
import { Avatar } from "@/components/ui/avatar";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -116,6 +117,11 @@ export function AccountSwitcher({
const removeAccount = useAuthStore((s) => s.removeAccount);
const logoutAll = useAuthStore((s) => s.logoutAll);
const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
const tWipe = useTranslations("offlineWipe");
// An account that signed out by itself beside this one: its data waits.
const [, noteWaitsChanged] = useState(0);
useEffect(() => onRefusedLoginsChanged(() => noteWaitsChanged((count) => count + 1)), []);
const waiting = siblingWaits(activeAccountId);
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
@@ -513,6 +519,30 @@ export function AccountSwitcher({
</div>
);
})}
{waiting.map((wait) => (
<button
key={wait.account}
type="button"
role="menuitem"
data-testid="account-needs-sign-in"
className="w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors hover:bg-muted"
onClick={() => {
setOpen(false);
router.push(`/login?mode=add-account&username=${encodeURIComponent(wait.username)}` as never);
}}
>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-destructive" />
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium truncate">{toUnicodeEmail(wait.label)}</span>
<span className="block text-xs text-foreground">{tWipe("needs_sign_in")}</span>
<span className="block text-xs text-muted-foreground">
{wait.unsent
? tWipe("switcher_waiting", { count: wait.unsent, date: waitDate(wait.until) })
: tWipe("switcher_kept", { date: waitDate(wait.until) })}
</span>
</span>
</button>
))}
</div>
{/* Mountain View: the account actions as rows closing the white card,
+1 -1
View File
@@ -20,7 +20,7 @@ export function InlineAppView({ apps, activeAppId, onClose, className }: InlineA
return (
<div className={cn('flex flex-col h-full bg-background', className)}>
{/* Header bar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
<div data-window-top-row="" className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
<h3 className="text-sm font-medium truncate">{activeApp?.name}</h3>
<IconButton label={tCommon("close")} onClick={onClose} className="text-muted-foreground">
<X />
+1
View File
@@ -596,6 +596,7 @@ export function NavigationRail({
})()}
<nav
data-window-rail=""
className={cn(
"flex flex-col flex-1 min-h-0 overflow-y-auto scroll-hidden",
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
@@ -0,0 +1,29 @@
"use client";
import { useEffect, useState } from "react";
import { applyControlsArea, watchTitlebarArea, watchWindowRows, type TitlebarArea } from "@/lib/window-controls-overlay";
/**
* The window's controls over the app, with no title bar row: see
* lib/window-controls-overlay.ts. Renders nothing, and changes nothing on the
* page, without an overlay. In the root layout, so that the pages outside [locale]
* (admin, setup) get it too; there are no messages up there.
*/
export function WindowControlsHost() {
const [area, setArea] = useState<TitlebarArea | null>(null);
useEffect(() => watchTitlebarArea((next) => {
applyControlsArea(next);
setArea(next);
}), []);
const key = area ? `${area.x},${area.width},${area.height}` : "";
useEffect(() => {
if (!area) return;
return watchWindowRows(area);
// `key` stands for area, a new object at each change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return null;
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopMenuBarEntry } from "@/lib/desktop/types";
/**
* Alt floats the menu's names over the top left for a moment, since a window with no
* frame has no native bar. Each name opens the app's menu as a native one
* (src/main/menu.ts in the desktop repo). Dismissed, nothing is left of it.
*/
export function WindowMenuBar() {
const t = useTranslations("desktopUi.menu");
const [entries, setEntries] = useState<DesktopMenuBarEntry[] | null>(null);
const stripRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
// What to do once the names are on screen: open that menu, or null to focus the first name.
const afterShow = useRef<string | null | undefined>(undefined);
const openMenu = useCallback((id: string) => {
const button = stripRef.current?.querySelector<HTMLElement>(`[data-menu-id="${CSS.escape(id)}"]`);
if (!button) return;
const rect = button.getBoundingClientRect();
getDesktopBridge()?.menu?.bar?.open(id, { x: rect.left, y: rect.bottom });
}, []);
const dismiss = useCallback(() => {
setEntries(null);
previousFocus.current?.focus?.();
previousFocus.current = null;
}, []);
useEffect(() => {
const bar = getDesktopBridge()?.menu?.bar;
if (!bar) return;
return bar.onEvent((event) => {
if (!event.visible) {
dismiss();
return;
}
void bar.entries().then((list) => {
previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null;
afterShow.current = event.open ?? null;
setEntries(list);
});
});
}, [dismiss]);
useEffect(() => {
const next = afterShow.current;
if (!entries || next === undefined) return;
afterShow.current = undefined;
if (next) openMenu(next);
else stripRef.current?.querySelector<HTMLElement>("button")?.focus();
}, [entries, openMenu]);
if (!entries) return null;
const onStripKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
const buttons = Array.from(stripRef.current?.querySelectorAll<HTMLElement>("button") ?? []);
const at = buttons.indexOf(document.activeElement as HTMLElement);
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
event.preventDefault();
const delta = event.key === "ArrowRight" ? 1 : -1;
buttons[(at + delta + buttons.length) % buttons.length]?.focus();
} else if (event.key === "ArrowDown") {
event.preventDefault();
buttons[at]?.click();
} else if (event.key === "Escape") {
event.preventDefault();
dismiss();
}
};
return (
<div
ref={stripRef}
role="menubar"
aria-label={t("bar_label")}
data-window-menu-bar=""
onKeyDown={onStripKey}
className="fixed start-2 top-2 z-[100002] flex items-center gap-0.5 rounded-lg border border-border bg-popover p-1 text-sm text-popover-foreground shadow-lg select-none"
>
{entries.map((entry) => (
<button
key={entry.id}
type="button"
role="menuitem"
aria-haspopup="menu"
data-menu-id={entry.id}
onClick={() => openMenu(entry.id)}
className="rounded px-2.5 py-1 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
>
{entry.label}
</button>
))}
</div>
);
}
-48
View File
@@ -1,48 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { applyTitlebarInset, watchTitlebarArea, type TitlebarArea } from "@/lib/window-controls-overlay";
/**
* The strip above the app in a window without a native title bar: the part
* the user drags the window by, with the window's title in small text. The
* native window controls sit next to it, outside the area it is given. The
* page roots leave this row empty through --titlebar-inset.
*
* Renders nothing, and changes nothing on the page, without an overlay. It
* repeats the title for sighted users only, so it stays out of the
* accessibility tree and out of the tab order.
*/
export function WindowTitleBar() {
const [area, setArea] = useState<TitlebarArea | null>(null);
const [title, setTitle] = useState("");
useEffect(() => watchTitlebarArea((next) => {
applyTitlebarInset(next);
setArea(next);
}), []);
const visible = area !== null;
useEffect(() => {
if (!visible) return;
const read = () => setTitle(document.title);
read();
// Each surface sets its own title, and Next puts <title> back on navigation.
const observer = new MutationObserver(read);
observer.observe(document.head, { childList: true, subtree: true, characterData: true });
return () => observer.disconnect();
}, [visible]);
if (!area) return null;
return (
<div
aria-hidden="true"
data-window-title-bar=""
className="window-title-bar fixed z-[100002] flex items-center bg-background ps-3 pe-3 text-xs text-muted-foreground select-none"
style={{ top: area.y, left: area.x, width: area.width, height: area.height }}
>
<span className="truncate">{title}</span>
</div>
);
}
@@ -0,0 +1,838 @@
import 'fake-indexeddb/auto';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MailTransferHost } from '@/components/mail-transfer/mail-transfer-host';
import { localExportAllowed, startLocalExport } from '@/components/mail-transfer/start-local-export';
import { OfflineSettings } from '@/components/offline/offline-settings';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { getStrictestDevices, devicesForAccount } from '@/lib/device-policy';
import { setDownloadGuard } from '@/lib/download-guard';
import type { ExportProgress } from '@/lib/mail-export/engine';
import { memorySink, type ExportFile, type ExportSink } from '@/lib/mail-export/sinks';
import { useOfflineStore } from '@/lib/offline/state';
import { runWipeCleaners } from '@/lib/offline/wipe-registry';
import { useAccountSubjects } from '@/lib/org/account-subjects';
import { runBeforeSignOut } from '@/lib/sign-out-cleanup';
import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { isJobActive, openLocalExport, useMailTransferStore } from '@/stores/mail-transfer-store';
import { usePolicyStore } from '@/stores/policy-store';
import { toast } from '@/stores/toast-store';
const FOLDERS: { id: string; name: string; role?: string; sortOrder: number }[] = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', sortOrder: 0 },
{ id: 'archive', name: 'Archive', sortOrder: 1 },
];
const RAW = new TextEncoder().encode('Subject: x\r\n\r\nbody\r\n');
/** A kept account where Inbox has 2 of 3 originals and Archive 1 of 1. */
function fakeStore(overrides: { rawBlob?: (blobId: string) => unknown; status?: { total: number; kept: number }; size?: { bytes: number; keptBytes: number } } = {}) {
const area = vi.fn(async (_account: string, name: string, params?: { mailboxId?: string; blobId?: string }): Promise<unknown> => {
if (name === 'mail.session') return { primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-a' } };
if (name === 'mail.rawStatus') return overrides.status ?? (params?.mailboxId === 'inbox' ? { total: 3, kept: 2 } : { total: 1, kept: 1 });
if (name === 'mail.rawBlob') return overrides.rawBlob ? overrides.rawBlob(params!.blobId!) : { ok: true, blob: new Blob([RAW]) };
if (name === 'mail.rawSize') return overrides.size ?? { bytes: 10, keptBytes: 10 };
if (name === 'mail.rawKept') return [];
return null;
});
const answer = vi.fn(async (_account: string, calls: [string, Record<string, unknown>, string][]) => {
const [name, args] = calls[0];
if (name === 'Mailbox/get') return { methodResponses: [['Mailbox/get', { list: FOLDERS }, '0']] };
if (name === 'Email/query') return { methodResponses: [['Email/query', { ids: (args.position as number) > 0 ? [] : ['m1'] }, '0']] };
return { methodResponses: [['Email/get', { list: [{ id: 'm1', blobId: 'raw-m1', size: RAW.length, keywords: {}, receivedAt: '2026-10-01T00:00:00Z', subject: 'x' }] }, '0']] };
});
return { supported: () => true, setScope: vi.fn(async () => {}), area, answer };
}
function setDevices(devices: Partial<typeof DEFAULT_ORGANISATION_POLICY.devices>) {
usePolicyStore.setState({
loaded: true, source: 'server', partial: false, subject: { email: 'alice@example.org', groups: [] },
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } },
} as never);
}
function fakeSink(): ExportSink & { write: ReturnType<typeof vi.fn>; abort: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> } {
const file: ExportFile = { name: 'a.mbox', size: 1, discard: vi.fn(async () => {}) };
return { kind: 'picker', write: vi.fn(async () => {}), close: vi.fn(async () => file), abort: vi.fn(async () => {}) };
}
/** The one lib/device-policy.ts installs, for tests that put their own in for a moment. */
const installedGuard: Parameters<typeof setDownloadGuard>[0] = (kind, subject) => {
const devices = subject === undefined ? getStrictestDevices() : devicesForAccount(subject as string | null);
return kind === 'export' ? !devices.noExport : !devices.noDownloads;
};
const realClient = { getServerUrl: () => 'https://mail.example.org', getAccountId: () => 'jmap-a', getMaxConcurrentRequests: () => 4 };
function setOnline(online: boolean) {
useOfflineStore.setState({ browserOffline: !online, downServers: [] });
}
beforeEach(() => {
useMailTransferStore.setState({ request: null, visible: false, job: null });
useAuthStore.setState({
client: realClient as never,
activeAccountId: 'acc',
getClientForAccount: ((id: string) => (id === 'acc' ? realClient : undefined)) as never,
getAllConnectedClients: (() => new Map([['acc', realClient]])) as never,
} as never);
useAccountStore.setState({ accounts: [{ id: 'acc', email: 'alice@example.org', username: 'alice@example.org', serverUrl: 'https://mail.example.org' }] } as never);
useOfflineStore.setState({
enabled: ['acc', 'other'], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], syncing: [], lastSyncedAt: {}, choices: { acc: { on: true } }, health: {},
storageFull: {}, progress: {}, persist: 'granted', browserOffline: false, downServers: [], startedOffline: false,
usage: { acc: { bytes: 5000, parts: [] } },
});
setDevices({});
setStoreBackend(fakeStore() as unknown as StoreBackend);
});
afterEach(() => {
cleanup();
setStoreBackend(null);
delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker;
delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
vi.unstubAllGlobals();
});
async function openDialog() {
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
await screen.findByTestId('local-export-summary');
}
/** Runs one export to its end through the job. */
async function finishedJob(sink = fakeSink()) {
expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: ['Inbox'] }], sink })).toBe(true);
await act(async () => { await useMailTransferStore.getState().job!.finished; });
return sink;
}
describe('before an export starts', () => {
it('says how many originals are here and how many come from the server when online', async () => {
await openDialog();
expect(screen.getByTestId('local-export-kept').textContent).toBe('kept');
expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online');
expect(screen.queryByTestId('local-export-reason')).toBeNull();
});
it('says offline how many can\'t be exported, and why', async () => {
setOnline(false);
await openDialog();
expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_offline');
expect(screen.getByTestId('local-export-reason').textContent).toBe('reason_not_kept');
});
it('gives attachments being off as the reason when they are', async () => {
setOnline(false);
useOfflineStore.setState({ choices: { acc: { on: true, attachments: false } } } as never);
await openDialog();
expect(screen.getByTestId('local-export-reason').textContent).toBe('reason_attachments_off');
});
it('counts only the folders chosen, and offers none of the rest', async () => {
setOnline(false);
await openDialog();
const area = (await import('@/lib/store/client/api')).getStore().area as ReturnType<typeof vi.fn>;
expect(area).toHaveBeenCalledWith('acc', 'mail.rawStatus', { jmap: 'jmap-a', mailboxId: 'inbox' });
const boxes = screen.getAllByTestId('local-export-folder') as HTMLInputElement[];
expect(boxes.map((box) => box.dataset.folder)).toEqual(['Inbox', 'Archive']);
fireEvent.click(boxes[0]);
// Only Archive now: all of its originals are here.
expect(screen.queryByTestId('local-export-missing')).toBeNull();
const all = screen.getByTestId('local-export-all') as HTMLInputElement;
expect(all.indeterminate).toBe(true);
fireEvent.click(all);
expect(boxes.every((box) => box.checked)).toBe(true);
});
it('can\'t start offline with no original of anything, and can as soon as the server is back', async () => {
setOnline(false);
setStoreBackend(fakeStore({ status: { total: 4, kept: 0 } }) as unknown as StoreBackend);
await openDialog();
expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_offline');
act(() => setOnline(true));
expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online');
});
it('puts the focus on the folders once they are read', async () => {
await openDialog();
await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId('local-export-all')));
});
it('can\'t start with nothing chosen', async () => {
await openDialog();
fireEvent.click(screen.getByTestId('local-export-all'));
expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByTestId('local-export-nothing')).toBeTruthy();
});
it('opens the picker from the click, with a name for what was chosen', async () => {
const writable = { write: vi.fn(async () => {}), close: vi.fn(async () => {}), abort: vi.fn(async () => {}) };
const picker = vi.fn(async () => ({ createWritable: async () => writable }));
(window as { showSaveFilePicker?: unknown }).showSaveFilePicker = picker;
await openDialog();
fireEvent.click(screen.getAllByTestId('local-export-folder')[1]);
fireEvent.click(screen.getByTestId('local-export-start'));
await waitFor(() => expect(picker).toHaveBeenCalled());
expect(picker).toHaveBeenCalledWith(expect.objectContaining({ suggestedName: 'Inbox.mbox' }));
await waitFor(() => expect(useMailTransferStore.getState().job?.progress.phase).toBe('done'));
expect(writable.close).toHaveBeenCalled();
});
});
describe('the device policy "No export"', () => {
it('locks the action in settings, with the reason beside it', () => {
setDevices({ noExport: true });
render(<OfflineSettings />);
expect((screen.getByTestId('local-export-open') as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText('settings_label').closest('[data-search-label]')!.querySelector('[data-org-locked]')).toBeTruthy();
});
it('leaves the action in settings open when it\'s off, and that opens the dialog', async () => {
render(<OfflineSettings />);
fireEvent.click(screen.getByTestId('local-export-open'));
expect(useMailTransferStore.getState().request).toEqual({ kind: 'local-export', account: 'acc' });
});
it('keeps the dialog from opening', () => {
setDevices({ noExport: true });
act(() => openLocalExport('acc'));
expect(useMailTransferStore.getState().request).toBeNull();
});
it('refuses to start, gives back the sink and reads nothing', () => {
const store = fakeStore();
setStoreBackend(store as unknown as StoreBackend);
setDevices({ noExport: true });
const sink = fakeSink();
expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(false);
expect(sink.abort).toHaveBeenCalled();
expect(store.answer).not.toHaveBeenCalled();
expect(useMailTransferStore.getState().job).toBeNull();
});
it('counts as on while the organisation\'s word isn\'t known, as at a first start offline', () => {
usePolicyStore.setState({ loaded: false, source: 'none' } as never);
expect(localExportAllowed('acc')).toBe(false);
usePolicyStore.setState({ loaded: true, source: 'fallback' } as never);
expect(localExportAllowed('acc')).toBe(false);
usePolicyStore.setState({ loaded: true, source: 'server', partial: true } as never);
expect(localExportAllowed('acc')).toBe(false);
usePolicyStore.setState({ loaded: true, source: 'cache', partial: false } as never);
expect(localExportAllowed('acc')).toBe(true);
});
it('stops a running export the moment it\'s switched on, and its file goes', async () => {
let release: () => void = () => {};
const store = fakeStore();
store.answer.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve({ methodResponses: [['Email/query', { ids: [] }, '0']] }); }));
setStoreBackend(store as unknown as StoreBackend);
const sink = fakeSink();
startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink });
const job = useMailTransferStore.getState().job!;
expect(isJobActive(job)).toBe(true);
await waitFor(() => expect(store.answer).toHaveBeenCalled());
act(() => setDevices({ noExport: true }));
expect(job.controller.signal.aborted).toBe(true);
release();
await act(async () => { await job.finished; });
expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled');
expect(sink.abort).toHaveBeenCalled();
expect(sink.close).not.toHaveBeenCalled();
});
it('asks about the account the export is for, not the active one', async () => {
useAuthStore.setState({ getClientForAccount: (() => realClient) as never } as never);
useAccountSubjects.setState({ settled: { other: true } } as never);
const asked: unknown[] = [];
setDownloadGuard((_kind, subject) => { asked.push(subject); return true; });
try {
const sink = fakeSink();
expect(startLocalExport({ account: 'other', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true);
expect(useMailTransferStore.getState().job?.scope).toBe('other');
await act(async () => { await useMailTransferStore.getState().job!.finished; });
expect(sink.close).toHaveBeenCalled();
expect(asked.length).toBeGreaterThan(1);
expect(new Set(asked)).toEqual(new Set(['other']));
} finally {
setDownloadGuard(installedGuard);
}
});
it('stops at the file when it came on without the store noticing, as the save picker has no other gate', async () => {
const sink = fakeSink();
let allowed = true;
setDownloadGuard(() => allowed);
try {
sink.write.mockImplementation(async () => { allowed = false; });
await finishedJob(sink);
expect(useMailTransferStore.getState().job?.progress.phase).toBe('failed');
expect(sink.close).not.toHaveBeenCalled();
expect(sink.abort).toHaveBeenCalled();
} finally {
setDownloadGuard(installedGuard);
}
});
it('shows the organisation as the reason when a real change of policy stopped it', async () => {
const sink = fakeSink();
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
// Through the policy store, the way a fetched policy arrives.
sink.write.mockImplementation(async () => { act(() => setDevices({ noExport: true })); });
await finishedJob(sink);
expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled');
expect(screen.getByTestId('local-export-stop-reason').textContent).toBe('export_blocked');
expect(sink.close).not.toHaveBeenCalled();
});
it('says why when the account stopped keeping mail here during the run', async () => {
const sink = fakeSink();
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
sink.write.mockImplementation(async () => { act(() => useOfflineStore.setState({ enabled: ['other'] })); });
await finishedJob(sink);
expect(screen.getByTestId('local-export-stop-reason').textContent).toBe('stopped_offline_off');
});
it('gives no reason beyond the title when the person cancelled it', async () => {
const sink = fakeSink();
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
sink.write.mockImplementation(async () => { act(() => useMailTransferStore.getState().cancelJob()); });
await finishedJob(sink);
expect(screen.queryByTestId('local-export-stop-reason')).toBeNull();
});
describe('with a scope the subject decides', () => {
/** Everyone may export; one domain or group may not. */
function scoped(match: Record<string, string[]>) {
const base = usePolicyStore.getState().rawPolicy;
usePolicyStore.setState({
rawPolicy: { ...base, scopes: [{ id: 'locked', label: 'Locked', match, policy: { organisation: { devices: { noExport: true } } } }] },
loaded: true, source: 'server', partial: false, subject: null, subjectPending: false,
} as never);
}
afterEach(() => {
usePolicyStore.setState({ rawPolicy: { ...usePolicyStore.getState().rawPolicy, scopes: [] }, subjectPending: false } as never);
usePolicyStore.getState().setPolicySubject(null);
});
it.each([['domain', { domains: ['example.org'] }], ['group', { groups: ['team@example.org'] }]])('%s: says no until the subject is known, whatever Everyone may do', (_kind, match) => {
scoped(match as Record<string, string[]>);
// What the policy alone would say: nobody has been matched yet.
expect(devicesForAccount('acc').noExport).toBe(false);
expect(localExportAllowed('acc')).toBe(false);
usePolicyStore.setState({ subjectPending: true } as never);
expect(localExportAllowed('acc')).toBe(false);
usePolicyStore.setState({ subjectPending: false } as never);
usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: ['team@example.org'] });
expect(devicesForAccount('acc').noExport).toBe(true);
expect(localExportAllowed('acc')).toBe(false);
});
it('opens once the subject is known and the scope isn\'t for it', () => {
scoped({ domains: ['elsewhere.example'] });
expect(localExportAllowed('acc')).toBe(false);
usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: [] });
expect(localExportAllowed('acc')).toBe(true);
});
it('waits for another account\'s own subject too', () => {
usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: [] });
useAuthStore.setState({ getClientForAccount: (() => realClient) as never } as never);
useAccountSubjects.setState({ settled: {} } as never);
expect(localExportAllowed('other')).toBe(false);
useAccountSubjects.setState({ settled: { other: true } } as never);
expect(localExportAllowed('other')).toBe(true);
});
});
describe('at the click on Export', () => {
function withPicker() {
const picker = vi.fn(async () => ({ createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }) }));
(window as { showSaveFilePicker?: unknown }).showSaveFilePicker = picker;
return picker;
}
it('opens the picker in the same tick as the click, so the gesture counts', async () => {
const picker = withPicker();
await openDialog();
fireEvent.click(screen.getByTestId('local-export-start'));
expect(picker).toHaveBeenCalledTimes(1);
await waitFor(() => expect(useMailTransferStore.getState().job?.progress.phase).toBe('done'));
});
it('opens no picker under the policy, and says why', async () => {
const picker = withPicker();
await openDialog();
setDevices({ noExport: true });
fireEvent.click(screen.getByTestId('local-export-start'));
expect(picker).not.toHaveBeenCalled();
expect((await screen.findByTestId('local-export-refused')).textContent).toBe('export_blocked');
expect(useMailTransferStore.getState().job).toBeNull();
});
it('opens no picker while the policy isn\'t known, and says that', async () => {
const picker = withPicker();
await openDialog();
usePolicyStore.setState({ subjectPending: true } as never);
fireEvent.click(screen.getByTestId('local-export-start'));
expect(picker).not.toHaveBeenCalled();
expect((await screen.findByTestId('local-export-refused')).textContent).toBe('policy_unknown');
usePolicyStore.setState({ subjectPending: false } as never);
});
});
});
describe('what the browser can hold', () => {
const SAFARI = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15';
const MB = 1024 * 1024;
/** A browser with no save picker but a private file system: the spool path. */
function spoolBrowser(agent: string, free: number) {
vi.stubGlobal('navigator', {
...navigator, userAgent: agent,
storage: { getDirectory: async () => ({}), estimate: async () => ({ quota: free + 100, usage: 100 }) },
locks: { request: async (name: string, _o: unknown, cb: (lock: unknown) => unknown) => cb({ name }) },
});
vi.stubGlobal('FileSystemFileHandle', class { createWritable() {} });
}
it('on WebKit stops it before it starts when the choice is over the cap, and says what to do', async () => {
spoolBrowser(SAFARI, 100 * 1024 * MB);
setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 600 * MB, keptBytes: 600 * MB } }) as unknown as StoreBackend);
await openDialog();
expect(screen.getByTestId('local-export-over-limit').textContent).toBe('over_limit');
expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true);
// Fewer folders fit.
fireEvent.click(screen.getAllByTestId('local-export-folder')[0]);
expect(screen.queryByTestId('local-export-over-limit')).toBeNull();
expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(false);
});
it('puts no such cap on Firefox', async () => {
spoolBrowser('Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', 100 * 1024 * MB);
setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 600 * MB, keptBytes: 600 * MB } }) as unknown as StoreBackend);
await openDialog();
expect(screen.queryByTestId('local-export-over-limit')).toBeNull();
expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(false);
});
it('says so when the browser has no room for the spool', async () => {
spoolBrowser('Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', 5 * MB);
setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 3 * MB, keptBytes: 3 * MB } }) as unknown as StoreBackend);
await openDialog();
expect(screen.getByTestId('local-export-no-room').textContent).toBe('no_room');
expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true);
});
it('counts only what will be written: offline, what is kept', async () => {
spoolBrowser('Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', 5 * MB);
setOnline(false);
setStoreBackend(fakeStore({ status: { total: 2, kept: 1 }, size: { bytes: 300 * MB, keptBytes: 1 * MB } }) as unknown as StoreBackend);
await openDialog();
expect(screen.queryByTestId('local-export-no-room')).toBeNull();
act(() => setOnline(true));
expect(screen.getByTestId('local-export-no-room')).toBeTruthy();
});
it('doesn\'t ask about room when the save picker writes elsewhere', async () => {
spoolBrowser('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36', 5 * MB);
(window as { showSaveFilePicker?: unknown }).showSaveFilePicker = vi.fn();
setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 300 * MB, keptBytes: 300 * MB } }) as unknown as StoreBackend);
await openDialog();
expect(screen.queryByTestId('local-export-no-room')).toBeNull();
expect(screen.queryByTestId('local-export-over-limit')).toBeNull();
});
it.each([
['ExportTooLargeError', 'stopped_limit'],
['QuotaExceededError', 'stopped_no_room'],
['TypeError', 'failed_generic'],
])('says %s in the user\'s language, never the browser\'s text', async (name, key) => {
const sink = fakeSink();
sink.write.mockImplementation(async () => { throw Object.assign(new Error('Some English from a browser'), { name, limit: 1024 }); });
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
await finishedJob(sink);
expect(useMailTransferStore.getState().job?.progress.phase).toBe('failed');
expect(screen.getByText(key)).toBeTruthy();
expect(screen.queryByText(/Some English/)).toBeNull();
});
it('says a message failed in the user\'s language too', async () => {
setStoreBackend(fakeStore({ rawBlob: () => { throw new Error('Some English from a server'); } }) as unknown as StoreBackend);
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
await finishedJob();
expect(screen.getByText(/message_failed/)).toBeTruthy();
expect(screen.queryByText(/Some English/)).toBeNull();
});
});
describe('what an export couldn\'t write', () => {
const notKept = () => setStoreBackend(fakeStore({ rawBlob: () => ({ ok: false, reason: 'not_kept' }) }) as unknown as StoreBackend);
it('counts it offline with the reason and no way to retry until online', async () => {
setOnline(false);
notKept();
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
await finishedJob();
expect((useMailTransferStore.getState().job?.progress as ExportProgress).notKept).toEqual({ not_kept: 1 });
expect(screen.getByTestId('local-export-not-kept-missing').textContent).toBe('not_kept_not_kept');
// Nothing was written: no file, and nothing to download again.
expect(screen.queryByTestId('mail-export-file')).toBeNull();
expect(screen.queryByTestId('mail-export-download')).toBeNull();
expect(screen.queryByTestId('local-export-not-kept-attachments')).toBeNull();
expect(screen.getByTestId('local-export-retry-hint').textContent).toBe('retry_offline');
expect((screen.getByTestId('local-export-retry') as HTMLButtonElement).disabled).toBe(true);
});
it('offers the retry once the connection is back, which goes back to the question with fresh counts', async () => {
setOnline(false);
notKept();
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
await finishedJob();
act(() => setOnline(true));
expect(screen.getByTestId('local-export-retry-hint').textContent).toBe('retry_online');
const area = (await import('@/lib/store/client/api')).getStore().area as ReturnType<typeof vi.fn>;
area.mockClear();
fireEvent.click(screen.getByTestId('local-export-retry'));
await screen.findByTestId('local-export-summary');
expect(useMailTransferStore.getState().job).toBeNull();
expect(area).toHaveBeenCalledWith('acc', 'mail.rawStatus', expect.anything());
expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online');
});
it('says the export stopped, not that a file is ready, when it ends hidden with nothing written', async () => {
setOnline(false);
notKept();
const warning = vi.spyOn(toast, 'warning');
const success = vi.spyOn(toast, 'success');
render(<MailTransferHost />);
await finishedJob();
expect(warning).toHaveBeenCalledWith('toast_stopped', expect.anything());
expect(success).not.toHaveBeenCalled();
warning.mockRestore();
success.mockRestore();
});
it('says when the picked file couldn\'t be removed after a run that wrote nothing', async () => {
setOnline(false);
notKept();
const sink = fakeSink();
sink.abort.mockImplementation(async () => ({ leftover: 'a.mbox' }));
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
await finishedJob(sink);
expect(sink.close).not.toHaveBeenCalled();
expect(screen.getByTestId('local-export-leftover').textContent).toBe('leftover_file');
});
it('says nothing about a file that was removed', async () => {
setOnline(false);
notKept();
const sink = fakeSink();
sink.abort.mockImplementation(async () => ({}));
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
await finishedJob(sink);
expect(sink.abort).toHaveBeenCalled();
expect(screen.queryByTestId('local-export-leftover')).toBeNull();
});
it('keeps to the chosen folders on the retry, also when a new folder has turned up', async () => {
setOnline(false);
notKept();
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
await screen.findByTestId('local-export-summary');
fireEvent.click(screen.getAllByTestId('local-export-folder')[1]);
await finishedJob();
act(() => setOnline(true));
FOLDERS.push({ id: 'new', name: 'New', sortOrder: 2 });
try {
fireEvent.click(screen.getByTestId('local-export-retry'));
await waitFor(() => expect(screen.getAllByTestId('local-export-folder')).toHaveLength(3));
const boxes = screen.getAllByTestId('local-export-folder') as HTMLInputElement[];
expect(boxes.map((box) => [box.dataset.folder, box.checked])).toEqual([['Inbox', true], ['Archive', false], ['New', false]]);
} finally {
FOLDERS.pop();
}
});
it('takes a new folder in when all were chosen', async () => {
setOnline(false);
notKept();
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
await screen.findByTestId('local-export-summary');
await finishedJob();
act(() => setOnline(true));
FOLDERS.push({ id: 'new', name: 'New', sortOrder: 2 });
try {
fireEvent.click(screen.getByTestId('local-export-retry'));
await waitFor(() => expect(screen.getAllByTestId('local-export-folder')).toHaveLength(3));
expect((screen.getAllByTestId('local-export-folder') as HTMLInputElement[]).every((box) => box.checked)).toBe(true);
} finally {
FOLDERS.pop();
}
});
it('makes no empty file when the folders turned out empty by the time it ran, and says so', async () => {
const store = fakeStore();
store.answer.mockImplementation(async (_account: string, calls: [string, Record<string, unknown>, string][]) => (
calls[0][0] === 'Mailbox/get'
? { methodResponses: [['Mailbox/get', { list: FOLDERS }, '0']] }
: { methodResponses: [['Email/query', { ids: [] }, '0']] }
) as never);
setStoreBackend(store as unknown as StoreBackend);
const sink = fakeSink();
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
await finishedJob(sink);
expect(sink.close).not.toHaveBeenCalled();
expect(sink.abort).toHaveBeenCalled();
expect(screen.getByTestId('local-export-wrote-nothing').textContent).toBe('nothing_kept');
});
it('offers no retry when everything was written', async () => {
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
await finishedJob();
expect(useMailTransferStore.getState().job?.progress.phase).toBe('done');
expect(screen.queryByTestId('local-export-retry')).toBeNull();
});
});
describe('while it runs', () => {
it('shows the progress, and Cancel stops it and gives up its file', async () => {
const sink = fakeSink();
let finish: () => void = () => {};
const store = fakeStore();
store.answer.mockImplementationOnce(() => new Promise((resolve) => { finish = () => resolve({ methodResponses: [['Email/query', { ids: [] }, '0']] }); }));
setStoreBackend(store as unknown as StoreBackend);
render(<MailTransferHost />);
act(() => openLocalExport('acc'));
act(() => { startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink }); });
expect(screen.getByRole('progressbar')).toBeTruthy();
expect(screen.getByTestId('mail-export-status').getAttribute('role')).toBe('status');
await waitFor(() => expect(store.answer).toHaveBeenCalled());
const job = useMailTransferStore.getState().job!;
fireEvent.click(screen.getByTestId('local-export-cancel'));
expect(job.controller.signal.aborted).toBe(true);
finish();
await act(async () => { await job.finished; });
expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled');
expect(sink.abort).toHaveBeenCalled();
expect(sink.close).not.toHaveBeenCalled();
expect(screen.getByText('cancelled_title')).toBeTruthy();
});
});
describe('stopped while the file is being finished', () => {
/** A sink whose close doesn't end until told, the way opening a large spool takes a while. */
function slowClose() {
const sink = fakeSink();
const file: ExportFile = { name: 'a.mbox', size: 1, discard: vi.fn(async () => {}) };
let finish: () => void = () => {};
let signal: AbortSignal | undefined;
sink.close.mockImplementation((given?: AbortSignal) => new Promise((resolve) => {
signal = given;
finish = () => resolve(file);
}));
return { sink, file, finish: () => finish(), signal: () => signal };
}
async function closing(sink: ExportSink) {
expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true);
await waitFor(() => expect((sink.close as ReturnType<typeof vi.fn>)).toHaveBeenCalled());
return useMailTransferStore.getState().job!;
}
it('passes the job\'s signal to the sink', async () => {
const slow = slowClose();
const job = await closing(slow.sink);
expect(slow.signal()).toBe(job.controller.signal);
slow.finish();
await act(async () => { await job.finished; });
});
it('a cancel: the file goes and the run counts as cancelled, also if the sink finished anyway', async () => {
const slow = slowClose();
const job = await closing(slow.sink);
act(() => useMailTransferStore.getState().cancelJob());
slow.finish();
await act(async () => { await job.finished; });
const progress = useMailTransferStore.getState().job?.progress as ExportProgress;
expect(progress.phase).toBe('cancelled');
expect(progress.file).toBeUndefined();
expect(slow.file.discard).toHaveBeenCalledWith(true);
});
it('a wipe that outlasts its wait: the job and its file still go when it ends', async () => {
render(<MailTransferHost />);
const slow = slowClose();
const job = await closing(slow.sink);
await act(async () => { await runWipeCleaners('acc'); });
expect(job.controller.signal.aborted).toBe(true);
// Still running after the 1.5 s the wipe gives it.
expect(useMailTransferStore.getState().job).not.toBeNull();
slow.finish();
await act(async () => { await job.finished; });
await waitFor(() => expect(useMailTransferStore.getState().job).toBeNull());
expect(slow.file.discard).toHaveBeenCalledWith(true);
});
it('a sign-out that outlasts its wait: the same', async () => {
render(<MailTransferHost />);
const slow = slowClose();
const job = await closing(slow.sink);
await act(async () => { await runBeforeSignOut('acc'); });
slow.finish();
await act(async () => { await job.finished; });
await waitFor(() => expect(useMailTransferStore.getState().job).toBeNull());
expect(slow.file.discard).toHaveBeenCalledWith(true);
});
it('a wipe after the dialog closed drops the finished file at once', async () => {
const revoke = vi.fn();
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: revoke }));
render(<MailTransferHost />);
await finishedJob(memorySink('a.mbox', 'application/mbox') as ReturnType<typeof fakeSink>);
act(() => useMailTransferStore.getState().close());
expect(useMailTransferStore.getState().job).toBeNull();
expect(revoke).not.toHaveBeenCalled();
await act(async () => { await runWipeCleaners('acc'); });
expect(revoke).toHaveBeenCalledWith('blob:x');
});
});
describe('an export is cleaned up', () => {
/** A job that doesn't end until it's aborted. */
function runningJob(account = 'acc') {
const sink = fakeSink();
const store = fakeStore();
// Like a store read: under way until the job is stopped.
store.answer.mockImplementation(() => new Promise((_resolve, reject) => {
const signal = useMailTransferStore.getState().job!.controller.signal;
const stop = () => reject(new DOMException('stopped', 'AbortError'));
if (signal.aborted) stop();
else signal.addEventListener('abort', stop);
}));
setStoreBackend(store as unknown as StoreBackend);
expect(startLocalExport({ account, jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true);
return { sink, job: useMailTransferStore.getState().job! };
}
function stubSweep() {
const removed: string[] = [];
const dir = {
keys: async function* () { yield 'old-spool'; },
removeEntry: async (name: string) => { removed.push(name); },
};
const root = {
getDirectoryHandle: async () => dir,
removeEntry: vi.fn(async () => {}),
};
vi.stubGlobal('navigator', { ...navigator, storage: { getDirectory: async () => root }, locks: { request: async (name: string, _o: unknown, cb: (lock: unknown) => unknown) => cb({ name }) } });
return { removed, root };
}
it('on a wipe of its account: stops the job and forgets it', async () => {
render(<MailTransferHost />);
const { job } = runningJob();
await act(async () => { await runWipeCleaners('acc'); });
expect(job.controller.signal.aborted).toBe(true);
expect(useMailTransferStore.getState().job).toBeNull();
});
it('a wipe of another account leaves the job alone', async () => {
render(<MailTransferHost />);
const { job } = runningJob();
await act(async () => { await runWipeCleaners('someone-else'); });
expect(job.controller.signal.aborted).toBe(false);
expect(useMailTransferStore.getState().job).toBe(job);
});
it('on a wipe of everything: also every spool, whoever holds it', async () => {
render(<MailTransferHost />);
const { job } = runningJob();
const { root } = stubSweep();
await act(async () => { await runWipeCleaners(null); });
expect(job.controller.signal.aborted).toBe(true);
expect(root.removeEntry).toHaveBeenCalledWith('bulwark-exports', { recursive: true });
});
it('on a wipe of one account: the spools nobody holds go too', async () => {
render(<MailTransferHost />);
const { removed, root } = stubSweep();
await act(async () => { await runWipeCleaners('acc'); });
expect(removed).toEqual(['old-spool']);
expect(root.removeEntry).not.toHaveBeenCalled();
});
it('on sign-out of its account: stops the job and forgets it', async () => {
render(<MailTransferHost />);
const { job } = runningJob();
await act(async () => { await runBeforeSignOut('acc'); });
expect(job.controller.signal.aborted).toBe(true);
expect(useMailTransferStore.getState().job).toBeNull();
});
it('frees a finished export\'s file at once on a wipe', async () => {
render(<MailTransferHost />);
const sink = fakeSink();
await finishedJob(sink);
const file = (useMailTransferStore.getState().job!.progress as ExportProgress).file!;
await act(async () => { await runWipeCleaners('acc'); });
expect(file.discard).toHaveBeenCalledWith(true);
});
it('when the account stops keeping mail here: stops the job', () => {
const { job } = runningJob();
act(() => useOfflineStore.setState({ enabled: ['other'] }));
expect(job.controller.signal.aborted).toBe(true);
});
it('at start-up: sweeps what a crash left', async () => {
const { removed } = stubSweep();
render(<MailTransferHost />);
await waitFor(() => expect(removed).toEqual(['old-spool']));
});
});
describe('the taskbar in the desktop app', () => {
it('shows a running export and takes it away at the end', async () => {
const setProgress = vi.fn();
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', os: { setProgress } };
render(<MailTransferHost />);
await finishedJob();
const calls = setProgress.mock.calls;
expect(calls[0]).toEqual([0, 'normal']);
expect(calls[calls.length - 1][0]).toBeNull();
});
it('says when it\'s paused', async () => {
const setProgress = vi.fn();
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', os: { setProgress } };
render(<MailTransferHost />);
const store = useMailTransferStore.getState();
act(() => store.startJob({ kind: 'export', controller: new AbortController(), progress: { phase: 'paused', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } }));
expect(setProgress).toHaveBeenLastCalledWith(0.25, 'paused');
});
it('asks nothing of a shell without it, or of a browser', async () => {
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' };
render(<MailTransferHost />);
await finishedJob();
expect(useMailTransferStore.getState().job?.progress.phase).toBe('done');
});
});
@@ -0,0 +1,340 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Dialog } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { downloadAllowed } from "@/lib/download-guard";
import { folderTree, safeName, type ExportFolder, type ExportFormat, type ExportProgress } from "@/lib/mail-export/engine";
import { MEMORY_EXPORT_LIMIT, openExportSink, spoolLimit } from "@/lib/mail-export/sinks";
import { scopeForAccount } from "@/lib/offline/offline-mode";
import { useOfflineStore } from "@/lib/offline/state";
import { getStore } from "@/lib/store/client/api";
import { formatFileSize } from "@/lib/utils";
import { useAccountStore } from "@/stores/account-store";
import { isJobActive, useMailTransferStore, type LocalExportRequest } from "@/stores/mail-transfer-store";
import { ExportProgressBody, saveMode } from "./mail-export-dialog";
import { localExportAllowed, localExportDecided, localExportReachable, localExportReader, primaryJmapAccount, startLocalExport } from "./start-local-export";
import { useFocusOnStage } from "./transfer-progress";
interface FolderRow {
folder: ExportFolder;
total: number;
kept: number;
bytes: number;
keptBytes: number;
}
/** `free`: what the browser says is left for this site; null where it doesn't say. */
type Loaded = { jmap: string; rows: FolderRow[]; free: number | null };
function today(): string {
return new Date().toISOString().slice(0, 10);
}
/** What an account keeps, folder by folder, for the question before an export starts. */
async function readFolders(account: string): Promise<Loaded> {
const jmap = await primaryJmapAccount(account);
if (!jmap) throw new Error("The account has no mail account in the store");
const folders = folderTree(await localExportReader(account, jmap).getMailboxes());
const rows: FolderRow[] = [];
for (const folder of folders) {
const params = { jmap, mailboxId: folder.mailboxId };
const status = await getStore().area<{ total: number; kept: number }>(account, "mail.rawStatus", params);
const size = await getStore().area<{ bytes: number; keptBytes: number }>(account, "mail.rawSize", params);
rows.push({ folder, ...status, ...size });
}
return { jmap, rows, free: await freeRoom() };
}
async function freeRoom(): Promise<number | null> {
try {
const estimate = await navigator.storage?.estimate?.();
if (typeof estimate?.quota !== "number") return null;
return Math.max(0, estimate.quota - (estimate.usage ?? 0));
} catch {
return null;
}
}
/** Settings > Backup & reset > Offline > Export kept mail: from the local store, also with no server. */
export function LocalExportDialog({ request }: { request: LocalExportRequest }) {
const t = useTranslations("offlineExport");
const tExport = useTranslations("mail_export");
const tPolicy = useTranslations("device_policy");
const locale = useLocale();
const id = useId();
const { account } = request;
const visible = useMailTransferStore((s) => s.visible);
const job = useMailTransferStore((s) => s.job);
const close = useMailTransferStore((s) => s.close);
const hide = useMailTransferStore((s) => s.hide);
const cancelJob = useMailTransferStore((s) => s.cancelJob);
const address = useAccountStore((s) => {
const entry = s.accounts.find((candidate) => candidate.id === account);
return entry?.email || entry?.username || account;
});
// Re-renders when the connection changes; what it means is asked below.
useOfflineStore((s) => s.browserOffline);
useOfflineStore((s) => s.downServers);
const reachable = localExportReachable(account);
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [excluded, setExcluded] = useState<ReadonlySet<string>>(new Set());
const [format, setFormat] = useState<ExportFormat>("mbox");
const [starting, setStarting] = useState(false);
const [refused, setRefused] = useState<string | null>(null);
const mode = saveMode();
const progress = job?.kind === "export" ? job.progress : null;
const active = isJobActive(job);
const stageButton = useRef<HTMLButtonElement>(null);
const allFolders = useRef<HTMLInputElement | null>(null);
const knownFolders = useRef<ReadonlySet<string>>(new Set());
useFocusOnStage(stageButton, progress ? (active ? "running" : "ended") : "setup");
const inSetup = !progress;
// Read again each time the setup shows, so a try online sees what's kept by then.
useEffect(() => {
if (!inSetup) return;
let live = true;
setLoadFailed(false);
readFolders(account).then(
(result) => {
if (!live) return;
// A folder that turned up since isn't one the person chose: after a try online
// only "All folders" takes it in.
const ids = result.rows.map((row) => row.folder.mailboxId);
const known = knownFolders.current;
knownFolders.current = new Set(ids);
setExcluded((was) => (was.size === 0 ? was : new Set([...was, ...ids.filter((folderId) => !known.has(folderId))])));
setLoaded(result);
},
() => { if (live) setLoadFailed(true); },
);
return () => { live = false; };
}, [account, inSetup]);
// The dialog opens before the folders are read, with only its footer to focus.
const foldersShown = inSetup && loaded !== null;
useEffect(() => {
if (foldersShown) allFolders.current?.focus();
}, [foldersShown]);
/** Why a run stopped, in the user's language, whatever the error said. */
function stopMessage(run: ExportProgress): string {
if (run.errorCode === "ExportForbiddenError" || !localExportAllowed(account)) return tPolicy("export_blocked");
if (run.errorCode === "ExportTooLargeError") return t("stopped_limit", { limit: formatFileSize(run.errorLimit ?? 0) });
if (run.errorCode === "QuotaExceededError") return t("stopped_no_room");
return t("failed_generic");
}
if (!visible) return null;
if (progress) {
const notKept = progress.notKept;
const notKeptCount = (notKept?.attachments_off ?? 0) + (notKept?.not_kept ?? 0);
const reason: unknown = job?.controller.signal.reason;
const leftover = progress.leftover;
const shown: ExportProgress = {
...progress,
// Nothing from a server or a browser reaches the user in English.
failures: progress.failures.map((failure) => ({ ...failure, reason: t("message_failed") })),
...(progress.phase === "failed" ? { error: stopMessage(progress) } : {}),
};
return (
<Dialog
isOpen
onClose={() => (active ? hide() : close())}
size="full-on-phone"
preventOutsideClose
title={progress.phase === "done" ? tExport("done_title") : progress.phase === "cancelled" ? tExport("cancelled_title") : progress.phase === "failed" ? tExport("failed_title") : tExport("running_title")}
testId="local-export-dialog"
footer={active ? (
<>
<Button ref={stageButton} variant="outline" onClick={hide}>{tExport("background")}</Button>
<Button variant="destructive" onClick={() => cancelJob()} aria-describedby={`${id}-status`} data-testid="local-export-cancel">{tExport("cancel_export")}</Button>
</>
) : (
<>
{progress.phase === "done" && progress.file?.download && (
<Button variant="outline" onClick={() => progress.file?.download?.()} data-testid="mail-export-download">{tExport("download_again")}</Button>
)}
{notKeptCount > 0 && (
<Button variant="outline" disabled={!reachable} onClick={() => useMailTransferStore.getState().open(request)} data-testid="local-export-retry">{t("retry")}</Button>
)}
<Button ref={stageButton} onClick={() => close()}>{tExport("close")}</Button>
</>
)}
>
<ExportProgressBody progress={shown} t={tExport} locale={locale} statusId={`${id}-status`} />
{progress.phase === "cancelled" && reason === "policy" && <p className="mt-4 text-sm text-destructive" role="alert" data-testid="local-export-stop-reason">{tPolicy("export_blocked")}</p>}
{progress.phase === "cancelled" && reason === "offline-off" && <p className="mt-4 text-sm text-destructive" role="alert" data-testid="local-export-stop-reason">{t("stopped_offline_off")}</p>}
{progress.phase === "done" && progress.exported === 0 && progress.failed === 0 && <p className="mt-4 text-sm text-foreground" data-testid="local-export-wrote-nothing">{t("nothing_kept")}</p>}
{leftover && <p className="mt-4 text-sm text-destructive break-words" role="alert" data-testid="local-export-leftover">{t("leftover_file", { name: leftover })}</p>}
{notKeptCount > 0 && (
<div className="mt-4 space-y-1 text-sm text-foreground" data-testid="local-export-not-kept">
{!!notKept?.attachments_off && <p data-testid="local-export-not-kept-attachments">{t("not_kept_attachments_off", { count: notKept.attachments_off })}</p>}
{!!notKept?.not_kept && <p data-testid="local-export-not-kept-missing">{t("not_kept_not_kept", { count: notKept.not_kept })}</p>}
<p className="text-xs text-muted-foreground" data-testid="local-export-retry-hint">{reachable ? t("retry_online") : t("retry_offline")}</p>
</div>
)}
</Dialog>
);
}
const rows = loaded?.rows ?? [];
const chosen = rows.filter((row) => !excluded.has(row.folder.mailboxId));
const total = chosen.reduce((sum, row) => sum + row.total, 0);
const kept = chosen.reduce((sum, row) => sum + row.kept, 0);
const missing = total - kept;
const single = chosen.length === 1;
const attachmentsOff = !scopeForAccount(account).scope.attachments;
// Offline with no original of anything: the save picker would ask where to put nothing.
const nothingToWrite = total === 0 || (kept === 0 && !reachable);
// What the run will need, against what this browser can give it. Asked up front: a
// tab killed with no message after hours is the outcome to avoid.
const needed = chosen.reduce((sum, row) => sum + (reachable ? row.bytes : row.keptBytes), 0);
const limit = mode === "memory" ? MEMORY_EXPORT_LIMIT : mode === "download" ? spoolLimit() : Infinity;
const overLimit = needed > limit;
const noRoom = mode === "download" && loaded?.free != null && needed > loaded.free;
const onStart = async () => {
if (!loaded || starting || chosen.length === 0) return;
// Before the picker, with nothing awaited, so the click still counts for it.
if (!localExportAllowed(account)) {
setRefused(localExportDecided(account) ? tPolicy("export_blocked") : t("policy_unknown"));
downloadAllowed("export", account);
return;
}
setRefused(null);
const zip = !single || format === "eml";
const base = single
? safeName(chosen[0].folder.path[chosen[0].folder.path.length - 1] ?? "", "mail")
: `${safeName(address, "mail")}-${today()}`;
setStarting(true);
try {
// The save picker must open straight from the click. In the desktop app it's the OS's own Save dialog.
const sink = await openExportSink(`${base}.${zip ? "zip" : "mbox"}`, zip ? "application/zip" : "application/mbox", zip ? ".zip" : ".mbox");
if (!sink) return;
startLocalExport({ account, jmap: loaded.jmap, format, folders: chosen.map((row) => row.folder), sink });
} finally {
setStarting(false);
}
};
const toggleFolder = (mailboxId: string, on: boolean) => {
const next = new Set(excluded);
if (on) next.delete(mailboxId);
else next.add(mailboxId);
setExcluded(next);
};
const formats: { value: ExportFormat; label: string; hint: string }[] = [
{ value: "mbox", label: single ? tExport("format_mbox_folder") : tExport("format_mbox_account"), hint: tExport("hint_mbox") },
{ value: "eml", label: single ? tExport("format_eml_folder") : tExport("format_eml_account"), hint: tExport("hint_eml") },
];
return (
<Dialog
isOpen
onClose={() => close()}
size="full-on-phone"
title={t("title")}
description={t("description")}
testId="local-export-dialog"
footer={(
<>
<Button variant="outline" onClick={() => close()}>{tExport("cancel")}</Button>
<Button onClick={() => void onStart()} disabled={starting || !loaded || nothingToWrite || overLimit || noRoom} data-testid="local-export-start">{tExport("start")}</Button>
</>
)}
>
<div className="mt-4 space-y-5">
{loadFailed ? (
<p className="text-sm text-destructive" role="alert" data-testid="local-export-load-failed">{t("load_failed")}</p>
) : !loaded ? (
<p className="text-sm text-muted-foreground" role="status" data-testid="local-export-loading">{t("loading")}</p>
) : (
<>
<fieldset>
<legend className="mb-2 text-sm font-medium text-foreground">{t("folders_label")}</legend>
<label className="flex items-center gap-2 py-1 text-sm font-medium text-foreground">
<input
type="checkbox"
checked={excluded.size === 0}
ref={(input) => {
allFolders.current = input;
if (input) input.indeterminate = excluded.size > 0 && excluded.size < rows.length;
}}
onChange={(event) => setExcluded(event.target.checked ? new Set() : new Set(rows.map((row) => row.folder.mailboxId)))}
data-testid="local-export-all"
/>
{t("all_folders")}
</label>
<ul className="max-h-56 overflow-y-auto rounded-md border border-border">
{rows.map((row) => (
<li key={row.folder.mailboxId} className="border-b border-border last:border-b-0">
<label className="flex items-start gap-2 px-3 py-2 text-sm text-foreground">
<input
type="checkbox"
className="mt-0.5 shrink-0"
checked={!excluded.has(row.folder.mailboxId)}
onChange={(event) => toggleFolder(row.folder.mailboxId, event.target.checked)}
data-testid="local-export-folder"
data-folder={row.folder.path.join("/")}
/>
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">{row.folder.path.join(" / ")}</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{t("folder_kept", { kept: row.kept, total: row.total })}</span>
</label>
</li>
))}
</ul>
</fieldset>
<fieldset>
<legend className="mb-2 text-sm font-medium text-foreground">{tExport("format_label")}</legend>
<div className="space-y-2">
{formats.map((f) => (
<label key={f.value} className="flex items-start gap-2 rounded-md border border-border p-3 text-sm text-foreground has-[:checked]:border-primary">
<input
type="radio"
name={`${id}-format`}
value={f.value}
checked={format === f.value}
onChange={() => setFormat(f.value)}
className="mt-0.5"
aria-describedby={`${id}-${f.value}-hint`}
data-testid={`local-export-format-${f.value}`}
/>
<span>
{f.label}
<span id={`${id}-${f.value}-hint`} className="block text-xs text-muted-foreground">{f.hint}</span>
</span>
</label>
))}
</div>
</fieldset>
<div className="space-y-1 text-sm text-foreground" role="status" aria-live="polite" data-testid="local-export-summary">
{total === 0 ? (
<p data-testid="local-export-nothing">{t("nothing_kept")}</p>
) : (
<p data-testid="local-export-kept">{t("kept", { kept, total })}</p>
)}
{missing > 0 && (
<p data-testid="local-export-missing" className={reachable ? undefined : "font-medium"}>
{reachable ? t("missing_online", { count: missing }) : t("missing_offline", { count: missing })}
</p>
)}
{missing > 0 && !reachable && (
<p className="text-xs text-muted-foreground" data-testid="local-export-reason">{attachmentsOff ? t("reason_attachments_off") : t("reason_not_kept")}</p>
)}
{overLimit && <p className="font-medium text-destructive" data-testid="local-export-over-limit">{t("over_limit", { limit: formatFileSize(limit), size: formatFileSize(needed) })}</p>}
{noRoom && !overLimit && <p className="font-medium text-destructive" data-testid="local-export-no-room">{t("no_room", { size: formatFileSize(needed), free: formatFileSize(loaded?.free ?? 0) })}</p>}
{refused && <p className="font-medium text-destructive" data-testid="local-export-refused">{refused}</p>}
</div>
<p className="text-xs text-muted-foreground">{tExport(`save_${mode}`)}</p>
</>
)}
</div>
</Dialog>
);
}
@@ -9,7 +9,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { isJobActive, useMailTransferStore, type ExportRequest } from "@/stores/mail-transfer-store";
import type { ExportFormat, ExportProgress } from "@/lib/mail-export/engine";
import { safeName } from "@/lib/mail-export/engine";
import { canPickSaveFile, canWritePrivateFile, memorySink, pickSaveFile, privateFileSink, sweepPrivateExports, type ExportSink } from "@/lib/mail-export/sinks";
import { canPickSaveFile, canWritePrivateFile, openExportSink } from "@/lib/mail-export/sinks";
import { formatFileSize } from "@/lib/utils";
import { formatClock, TransferBar, useFocusOnStage } from "./transfer-progress";
import { startExport } from "./start-export";
@@ -19,7 +19,7 @@ function today(): string {
}
/** Where the file will go in this browser, said before the export starts. */
function saveMode(): "picker" | "download" | "memory" {
export function saveMode(): "picker" | "download" | "memory" {
if (canPickSaveFile()) return "picker";
return canWritePrivateFile() ? "download" : "memory";
}
@@ -57,21 +57,10 @@ export function MailExportDialog({ request }: { request: ExportRequest }) {
const name = `${base}.${zip ? "zip" : "mbox"}`;
const mime = zip ? "application/zip" : "application/mbox";
setStarting(true);
let sink: ExportSink | null = null;
try {
// The save picker must open straight from the click.
if (mode === "picker") {
try {
sink = await pickSaveFile(name, mime, zip ? ".zip" : ".mbox");
} catch (error) {
if ((error as { name?: string } | null)?.name === "AbortError") return;
sink = null;
}
}
if (!sink) {
await sweepPrivateExports();
sink = (await privateFileSink(name, mime)) ?? memorySink(name, mime);
}
const sink = await openExportSink(name, mime, zip ? ".zip" : ".mbox");
if (!sink) return;
startExport({
format,
accountId: mailbox?.accountId || client.getAccountId(),
@@ -165,7 +154,7 @@ export function MailExportDialog({ request }: { request: ExportRequest }) {
type T = ReturnType<typeof useTranslations>;
function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) {
export function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) {
const running = p.phase === "running" || p.phase === "paused";
// The live region says only what changes the user's plans (paused, done);
// the count beside it would otherwise be read out several times a second.
+54 -12
View File
@@ -4,18 +4,47 @@ import { useEffect, useRef } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Loader2, Pause } from "@/components/icons";
import { isJobActive, useMailTransferStore } from "@/stores/mail-transfer-store";
import { showTaskbarProgress } from "@/lib/desktop/taskbar-progress";
import { onDeviceWipe } from "@/lib/offline/wipe-registry";
import { onBeforeSignOut } from "@/lib/sign-out-cleanup";
import { clearCheckpointsFor } from "@/lib/mail-import/checkpoints";
import { sweepPrivateExports } from "@/lib/mail-export/sinks";
import { dropExportFiles, sweepPrivateExports } from "@/lib/mail-export/sinks";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { MailImportDialog } from "./mail-import-dialog";
import { MailExportDialog } from "./mail-export-dialog";
import { LocalExportDialog } from "./local-export-dialog";
import { formatClock } from "./transfer-progress";
/** How long sign-out waits for a cancelled job to stop (its budget is 2 s). */
const SIGN_OUT_WAIT_MS = 1500;
/**
* Stops the job of an account that goes, and frees what its export kept in this
* browser. `all`: whoever's job it is. Another account's job goes on.
*/
async function stopJobOf(accountId: string): Promise<void> {
// Finished exports waiting out their grace period, with the dialog long closed.
dropExportFiles(accountId);
const store = useMailTransferStore.getState();
const job = store.job;
if (!job || !(accountId === "all" || !job.scope || job.scope === accountId)) return;
const forget = () => {
const now = useMailTransferStore.getState();
if (now.job?.controller === job.controller) now.close({ now: true });
};
if (isJobActive(job)) {
store.cancelJob("signout");
await Promise.race([job.finished, new Promise((resolve) => setTimeout(resolve, SIGN_OUT_WAIT_MS))]);
// Still going after the wait: close() leaves a running job be, so it goes when it ends.
if (isJobActive(useMailTransferStore.getState().job)) {
void job.finished?.then(forget, forget);
return;
}
}
forget();
}
/**
* Draws the mail import and export dialogs, and while it is hidden with a job running,
* a chip in the corner that shows the progress and brings it back. Mounted
@@ -31,18 +60,16 @@ export function MailTransferHost() {
// Another account signing out leaves the job alone. The job's checkpoint
// goes after the job has stopped, so a late write can't bring it back.
useEffect(() => onBeforeSignOut(async (accountId) => {
const store = useMailTransferStore.getState();
const job = store.job;
if (job && (accountId === "all" || !job.scope || job.scope === accountId)) {
if (isJobActive(job)) {
store.cancelJob("signout");
await Promise.race([job.finished, new Promise((resolve) => setTimeout(resolve, SIGN_OUT_WAIT_MS))]);
}
useMailTransferStore.getState().close({ now: true });
}
await stopJobOf(accountId);
if (accountId !== "all") await clearCheckpointsFor(accountId);
}), []);
// A wipe takes the same, and every spool no tab holds. `null` is everything on this device.
useEffect(() => onDeviceWipe(async (account) => {
await stopJobOf(account ?? "all");
await sweepPrivateExports({ force: account === null });
}), []);
// Export files a closed tab or a crash left in the browser's private
// storage go when the app starts and when someone signs in.
const activeAccountId = useAuthStore((s) => s.activeAccountId);
@@ -50,6 +77,20 @@ export function MailTransferHost() {
if (!isJobActive(useMailTransferStore.getState().job)) void sweepPrivateExports();
}, [activeAccountId]);
// The desktop app shows a running export on its taskbar button.
const exportPhase = job?.kind === "export" ? job.progress.phase : null;
const exportFraction = job?.kind === "export" ? job.progress.fraction : 0;
const onTaskbar = useRef(false);
useEffect(() => {
if (exportPhase === "running" || exportPhase === "paused") {
onTaskbar.current = true;
showTaskbarProgress(exportFraction, exportPhase === "paused" ? "paused" : "normal");
} else if (onTaskbar.current) {
onTaskbar.current = false;
showTaskbarProgress(null);
}
}, [exportPhase, exportFraction]);
// A job that ends while its dialog is hidden says so, with a way back to its summary.
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
@@ -64,8 +105,8 @@ export function MailTransferHost() {
if (job.kind === "import") {
if (phase === "done") toast.success(tImport("toast_done", { imported: job.progress.imported, failed: job.progress.failed }), { action });
else toast.warning(tImport("toast_stopped"), { action });
} else if (phase === "done") {
toast.success(tExport("toast_done", { name: job.progress.file?.name ?? "" }), { action });
} else if (phase === "done" && job.progress.file) {
toast.success(tExport("toast_done", { name: job.progress.file.name }), { action });
} else {
toast.warning(tExport("toast_stopped"), { action });
}
@@ -77,6 +118,7 @@ export function MailTransferHost() {
<>
{request?.kind === "import" && <MailImportDialog request={request} />}
{request?.kind === "export" && <MailExportDialog request={request} />}
{request?.kind === "local-export" && <LocalExportDialog request={request} />}
{!visible && isJobActive(job) && <TransferChip />}
</>
);
@@ -0,0 +1,116 @@
import { downloadAllowed, downloadAllowedQuietly } from "@/lib/download-guard";
import { localExportClient } from "@/lib/mail-export/local-store-client";
import { runExport, type ExportFolder, type ExportFormat } from "@/lib/mail-export/engine";
import { guardedSink, type ExportSink } from "@/lib/mail-export/sinks";
import { scopeDecided } from "@/lib/offline/offline-mode";
import { isOfflineNow, useOfflineStore } from "@/lib/offline/state";
import { getStore } from "@/lib/store/client/api";
import type { StoreSession } from "@/lib/store/engine/transport";
import { useAuthStore } from "@/stores/auth-store";
import { useMailTransferStore } from "@/stores/mail-transfer-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useAccountSubjects } from "@/lib/org/account-subjects";
const MAIL_CAPABILITY = "urn:ietf:params:jmap:mail";
/**
* Whether the organisation has said what applies to this account, down to its
* groups. A start offline may not have heard it, and a group or domain scope may
* forbid what Everyone may do: not known means no.
*/
export function localExportDecided(account: string): boolean {
return scopeDecided(account);
}
/** Whether the organisation lets this account export. */
export function localExportAllowed(account: string): boolean {
return localExportDecided(account) && downloadAllowedQuietly("export", account);
}
function clientOf(account: string) {
const auth = useAuthStore.getState();
return auth.getClientForAccount?.(account) ?? (account === auth.activeAccountId ? auth.client : undefined) ?? null;
}
/** The real client while its server can be reached. */
function reachableClient(account: string) {
const client = clientOf(account);
if (!client || isOfflineNow(client.getServerUrl())) return null;
return client;
}
/** Whether a message with no original here could be fetched right now. */
export function localExportReachable(account: string): boolean {
return reachableClient(account) !== null;
}
/** The account's own mail account in the store. Shared accounts aren't offered yet. */
export async function primaryJmapAccount(account: string): Promise<string | null> {
const session = await getStore().area<StoreSession | null>(account, "mail.session");
return session?.primaryAccounts?.[MAIL_CAPABILITY] ?? clientOf(account)?.getAccountId() ?? null;
}
export function localExportReader(account: string, jmap: string) {
return localExportClient({ account, jmap, store: getStore(), online: () => reachableClient(account) });
}
export interface StartLocalExportInput {
account: string;
jmap: string;
format: ExportFormat;
folders: ExportFolder[];
sink: ExportSink;
}
/** Runs an export from the local store as the dialog's job. False, and nothing read, when the policy says no. */
export function startLocalExport(input: StartLocalExportInput): boolean {
const { account, jmap, format, folders } = input;
if (!localExportAllowed(account) || !downloadAllowed("export", account)) {
void input.sink.abort().catch(() => {});
return false;
}
const controller = new AbortController();
// Stops at once, also for an account that isn't the active one, and when no write is due.
const unwatchPolicy = usePolicyStore.subscribe(() => {
if (!localExportAllowed(account)) controller.abort("policy");
});
// What another account's groups turn out to be can say no as well.
const unwatchSubjects = useAccountSubjects.subscribe(() => {
if (!localExportAllowed(account)) controller.abort("policy");
});
const unwatchOffline = useOfflineStore.subscribe((state) => {
if (!state.enabled.includes(account)) controller.abort("offline-off");
});
let ended: () => void = () => {};
const finished = new Promise<void>((resolve) => { ended = resolve; });
useMailTransferStore.getState().startJob({
kind: "export",
controller,
scope: account,
finished,
progress: { phase: "running", exported: 0, failed: 0, failures: [], total: 0, fraction: 0 },
});
void (async () => {
try {
const result = await runExport({
client: localExportReader(account, jmap),
accountId: jmap,
folders,
format,
// The save picker passes the guard in saveBlobAs, so every write asks.
sink: guardedSink(input.sink, () => localExportAllowed(account)),
signal: controller.signal,
requestsPerMinute: Infinity,
noEmptyFile: true,
onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress),
});
useMailTransferStore.getState().updateProgress(result);
} finally {
unwatchPolicy();
unwatchSubjects();
unwatchOffline();
ended();
}
})();
return true;
}
+85 -50
View File
@@ -63,6 +63,10 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePaneSize } from "@/hooks/use-pane-size";
import { usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
import { useDesktopCommands } from "@/lib/desktop/commands";
import { useDesktopPrefs } from "@/lib/desktop/preferences";
import { hasDesktopWindows } from "@/lib/popout/support";
import { composerDataFromDraft } from "@/lib/draft-composer-data";
import { useUndoShortcut } from "@/hooks/use-undo-shortcut";
import { confirmMailRemoval } from "@/lib/mail-removal-confirm";
import { findNextUnreadIndex } from "@/lib/next-unread-email";
@@ -144,6 +148,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions";
import { setMailLinkOpener } from "@/lib/desktop/mail-links";
import { requestTabReply } from "@/lib/pwa-notify/tab-reply";
import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state";
import { pendingHandoffs, subscribeHandoff, takeHandoff, type Handoff } from "@/lib/launch-handoff";
@@ -1216,6 +1221,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Mountain View keeps the ticked rows while a message is read (#1231). The
// shortcuts then act on the message on screen, as its toolbar does, and
// reach the selection again back in the list.
const openEmailInWindow = useOpenEmailInWindow();
const shortcutsTargetSelection = selectedEmailKeys.size > 0 && !(mvShell && !cursorOnly && selectedEmail);
const singleKeyShortcuts = useSettingsStore((s) => s.singleKeyShortcuts);
useKeyboardShortcuts({
@@ -1259,6 +1265,59 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
}, isAuthenticated && !!client);
// The desktop app's menu bar: the same handlers as the keys, and what's enabled
// from what's on screen (lib/desktop/commands.ts).
const desktopTarget = !showComposer && (!!selectedEmail || selectedEmailKeys.size > 0);
const desktopSingle = !showComposer && !!selectedEmail;
const mailLayoutSetting = useSettingsStore((s) => s.mailLayout);
const layoutFixed = interfaceLayout === 'mountain-view';
useDesktopCommands({
'file.newMessage': keyboardHandlers.onCompose,
'edit.search': keyboardHandlers.onFocusSearch,
'view.refresh': () => void handleManualRefresh(),
'view.layout.split': () => useSettingsStore.getState().updateSetting('mailLayout', 'split'),
'view.layout.horizontal': () => useSettingsStore.getState().updateSetting('mailLayout', 'horizontal'),
'view.layout.focus': () => useSettingsStore.getState().updateSetting('mailLayout', 'focus'),
'message.reply': keyboardHandlers.onReply,
'message.replyAll': keyboardHandlers.onReplyAll,
'message.forward': keyboardHandlers.onForward,
'message.markRead': () => void keyboardHandlers.onMarkAsRead?.(),
'message.markUnread': () => void keyboardHandlers.onMarkAsUnread?.(),
'message.star': () => void keyboardHandlers.onToggleStar?.(),
'message.move': () => { if (selectedEmail) setMoveDialogEmail(selectedEmail); },
'message.archive': keyboardHandlers.onArchive ? () => void keyboardHandlers.onArchive?.() : undefined,
'message.spam': () => void keyboardHandlers.onToggleSpam?.(),
'message.delete': () => void keyboardHandlers.onDelete?.(),
'message.openWindow': () => { if (selectedEmail) openEmailInWindow?.(selectedEmail); },
'go.inbox': () => keyboardHandlers.onGoToMailbox('inbox'),
'go.sent': () => keyboardHandlers.onGoToMailbox('sent'),
'go.drafts': () => keyboardHandlers.onGoToMailbox('drafts'),
'go.nextMessage': keyboardHandlers.onNextEmail,
'go.previousMessage': keyboardHandlers.onPreviousEmail,
'go.nextUnread': keyboardHandlers.onNextUnread,
}, {
'view.refresh': true,
'view.layout.split': { enabled: !layoutFixed, checked: mailLayoutSetting === 'split' },
'view.layout.horizontal': { enabled: !layoutFixed, checked: mailLayoutSetting === 'horizontal' },
'view.layout.focus': { enabled: !layoutFixed, checked: mailLayoutSetting === 'focus' },
'message.reply': desktopSingle,
'message.replyAll': desktopSingle,
'message.forward': desktopSingle,
'message.markRead': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && selectedEmail.keywords?.$seen),
'message.markUnread': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && !selectedEmail.keywords?.$seen),
'message.star': desktopSingle,
'message.move': desktopSingle,
'message.archive': desktopTarget && archiveActionEnabled,
'message.spam': desktopTarget,
'message.delete': desktopTarget,
'message.openWindow': desktopSingle && !!openEmailInWindow,
'go.sent': true,
'go.drafts': true,
'go.nextMessage': !showComposer,
'go.previousMessage': !showComposer,
'go.nextUnread': !showComposer,
}, isAuthenticated && !isEmbedded);
// Shared mail refresh: re-fetch mailboxes (unread counts) + the active list
// via JMAP. Used by both the browser refresh gestures and the toolbar button.
const [isManualRefreshing, setIsManualRefreshing] = useState(false);
@@ -2266,7 +2325,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const composerSource = usePinnedPerSession(composerSessionId, selectedEmail ? emailWriteSource(selectedEmail, client) : undefined);
const popoutsOn = usePopoutsAvailable();
const openEmailInWindow = useOpenEmailInWindow();
const desktopDoubleClick = useDesktopPrefs().openOnDoubleClick && hasDesktopWindows() && !isMobile && !!openEmailInWindow;
// Text from a closing pop-out that couldn't be saved: the "Continue draft"
// slot, once it is free.
useEffect(() => {
@@ -2604,17 +2663,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
draft = await emailHooks.onBeforeEditDraft.transform(draft);
const { body: draftBody, bodyText, plainTextMode: draftPlainTextMode } = getDraftComposerBody(draft);
const { bodyText } = getDraftComposerBody(draft);
// Restore the identity the draft was composed with. Match the saved From
// (name + address) against the same list the composer renders — the flat
// cross-account list when multi-account is on — so a draft from a non-active
// account, or one of two identities sharing an address, is restored rather
// than reset to the default.
// The list the composer renders: the flat cross-account one when multi-account is on.
const composerIdentities = multiAccountIdentities.enabled
? multiAccountIdentities.allIdentities
: identities;
const matchedIdentityId = findDraftIdentityIdForLogin(composerIdentities, draft.from?.[0], localAccountId);
const draftRecipients = (list: typeof draft.to) => (list ?? []).map(a => a.email).filter(Boolean);
if (!(await withComposeGate('compose', {
@@ -2627,46 +2681,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Increment session ID to force the composer to remount with fresh state,
// even if it was already open (e.g. right-clicking a draft while composing).
setComposerSessionId(id => id + 1);
setPendingDraft({
to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
bcc: draft.bcc?.map(a => a.email).filter(Boolean).join(', ') || '',
// Drafts saved before #1189 stored an empty subject as the composer's
// "(No Subject)" placeholder; reopen those with an empty field.
subject: draft.subject && draft.subject !== t('email_composer.no_subject') ? draft.subject : '',
body: draftBody,
// Re-open in the format the draft was written in (#1022).
plainTextMode: draftPlainTextMode,
showCc: (draft.cc?.length || 0) > 0,
showBcc: (draft.bcc?.length || 0) > 0,
selectedIdentityId: matchedIdentityId,
subAddressTag: '',
mode: 'compose',
draftId: draft.id,
draftAccountId: accountId,
draftLocalAccountId: localAccountId,
// A reply draft re-opens in compose mode; its threading headers must
// come along or the reply leaves its thread.
inReplyTo: draft.inReplyTo ?? undefined,
references: draft.references ?? undefined,
priority: messagePriorityFromHeaders(draft.headers),
// A sent message brought back (Undo, Cancel and edit) has no draft
// marker: its SIO-Label says which label it went out with.
classification: reopenedLabelChoice(draft.headers, localAccountId),
// Existing server-side attachments must ride along, or the composer
// starts empty and the next save/send silently rebuilds the draft
// without them (#849).
attachments: (draft.attachments ?? [])
.filter(a => !!a.blobId)
.map(a => ({
blobId: a.blobId,
name: a.name,
type: a.type,
size: a.size,
cid: a.cid,
disposition: a.disposition,
})),
});
setPendingDraft(composerDataFromDraft(draft, {
accountId,
localAccountId,
identities: composerIdentities,
noSubject: t('email_composer.no_subject'),
}));
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -2936,6 +2956,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
}, []);
// The desktop app's tray, jump list and toasts, like any other permalink. A live
// composer's text goes through its own "Save or discard?" first.
useEffect(() => {
return setMailLinkOpener((link, options) => {
const open = () => void applyMailDeepLinkRef.current(link, options);
const requestClose = composerRequestCloseRef.current;
if (link.kind === 'message' && link.reply && composerOpenRef.current && requestClose) requestClose(open);
else open();
});
}, []);
// A plugin opening a message (api.ui.openEmail), as a click on a search
// result entry naming one does: handleEmailSelect, set once it is defined.
// Not inside the Pro shell, which keeps this tab mounted while hidden and
@@ -5539,7 +5570,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onEmailSelect={handleEmailSelect}
onOpenSearchResult={handleEmailSelect}
onEmailPrefetch={handleEmailPrefetch}
onEmailDoubleClick={isEmbedded ? ((email) => {
onEmailDoubleClick={!isEmbedded ? (desktopDoubleClick ? (email) => {
// A draft goes back in the composer, as it does from a single click.
if (email.keywords?.$draft) void handleEditDraft(email);
else openEmailInWindow?.(email);
} : undefined) : ((email) => {
const store = useProTabStore.getState();
const hasSplit = store.tabs.some((tab) => tab.paneId === 'split');
// In a split, open on the *other* side so this list stays
@@ -5570,7 +5605,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
mailboxId: selectedMailbox,
title: email.subject?.trim() || t('email_composer.new_message'),
}, { pane: targetPane, reuseReader: hasSplit });
}) : undefined}
})}
onOpenConversation={handleOpenConversation}
// Context menu handlers
onReply={(email) => {
@@ -0,0 +1,62 @@
import { act, render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// The one toast when an account beside this one signs out by itself.
const warning = vi.hoisted(() => vi.fn());
vi.mock('@/stores/toast-store', () => ({ toast: { warning } }));
vi.mock('@/lib/offline/device-wipe', () => ({ startDeviceWipe: () => () => {} }));
const { DeviceWipeHost } = await import('../device-wipe-host');
const { useAuthStore } = await import('@/stores/auth-store');
const { markLoginRefused, setRefusedCount } = await import('@/lib/devices/wipe-state');
const { useOfflineStore, markEvicted } = await import('@/lib/offline/state');
const A = 'a@example.com';
const B = 'b@example.com';
beforeEach(() => {
warning.mockClear();
localStorage.clear();
useOfflineStore.setState({ enabled: [A, B] });
useAuthStore.setState({ activeAccountId: A, isAuthenticated: true } as never);
});
function refuseB(): void {
markLoginRefused(B, { certain: true, seenBy: [A], label: B, username: B });
markEvicted(B);
}
describe('the toast', () => {
it('comes once, when the count is in, and names the account, the count and the date', () => {
render(<DeviceWipeHost />);
act(() => refuseB());
expect(warning).not.toHaveBeenCalled();
act(() => setRefusedCount(B, 2));
expect(warning).toHaveBeenCalledTimes(1);
expect(warning.mock.calls[0][0]).toBe('sibling_waiting');
act(() => setRefusedCount(B, 2));
expect(warning).toHaveBeenCalledTimes(1);
});
it('says the other thing with nothing unsent, or no number', () => {
render(<DeviceWipeHost />);
act(() => { refuseB(); setRefusedCount(B, null); });
expect(warning.mock.calls[0][0]).toBe('sibling_kept');
});
it('isn\'t for someone signed in later', () => {
useAuthStore.setState({ activeAccountId: 'stranger@example.com' } as never);
refuseB();
setRefusedCount(B, 2);
render(<DeviceWipeHost />);
expect(warning).not.toHaveBeenCalled();
});
it('shows at the next page for the one it was meant for, if it got no chance before', () => {
refuseB();
setRefusedCount(B, 2);
render(<DeviceWipeHost />);
expect(warning).toHaveBeenCalledTimes(1);
});
});
@@ -46,6 +46,29 @@ describe('Settings > Offline', () => {
}
});
it('offers removing everything where offline mode exists', () => {
render(<OfflineSettings />);
expect(screen.getByTestId('wipe-device-section')).toBeTruthy();
});
it('doesn\'t where it can\'t be used: a private window, a store that isn\'t there, an organisation\'s no', () => {
useOfflineStore.setState({ storageBlocked: true });
const privateWindow = render(<OfflineSettings />);
expect(screen.queryByTestId('wipe-device-section')).toBeNull();
privateWindow.unmount();
useOfflineStore.setState({ storageBlocked: false });
setStoreBackend({ supported: () => false } as unknown as StoreBackend);
const noStore = render(<OfflineSettings />);
expect(screen.queryByTestId('wipe-device-section')).toBeNull();
noStore.unmount();
setStoreBackend({ supported: () => true } as unknown as StoreBackend);
setNoOffline(true);
render(<OfflineSettings />);
expect(screen.queryByTestId('wipe-device-section')).toBeNull();
});
it('is off until the user turns it on in this browser', () => {
render(<OfflineSettings />);
const toggle = screen.getByTestId('offline-switch');
@@ -0,0 +1,57 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const wipe = vi.hoisted(() => ({ whole: vi.fn(async () => {}), unsent: 0 as number | null }));
vi.mock('@/lib/offline/device-wipe', () => ({ wipeWholeDevice: wipe.whole }));
vi.mock('@/lib/offline/offline-mode', () => ({ unsentForWipe: async () => wipe.unsent }));
import { WipeDeviceSection } from '../wipe-device-section';
beforeEach(() => {
wipe.whole.mockClear();
wipe.unsent = 0;
});
describe('the wipe in settings', () => {
it('says what goes before anything happens', async () => {
render(<WipeDeviceSection />);
fireEvent.click(screen.getByTestId('wipe-device-button'));
const details = await screen.findByTestId('wipe-device-details');
for (const key of ['confirm_mail', 'confirm_keys', 'confirm_plugins', 'confirm_files', 'confirm_templates', 'confirm_accounts']) {
expect(details.textContent).toContain(key);
}
expect(details.textContent).not.toContain('confirm_unsent');
expect(wipe.whole).not.toHaveBeenCalled();
});
it('names unsent mail when there is some', async () => {
wipe.unsent = 2;
render(<WipeDeviceSection />);
fireEvent.click(screen.getByTestId('wipe-device-button'));
expect((await screen.findByTestId('wipe-device-details')).textContent).toContain('confirm_unsent');
});
it('says it can\'t tell, not that there\'s nothing, when the count is unknown', async () => {
wipe.unsent = null;
render(<WipeDeviceSection />);
fireEvent.click(screen.getByTestId('wipe-device-button'));
const details = await screen.findByTestId('wipe-device-details');
expect(details.textContent).toContain('confirm_unsent_unknown');
});
it('wipes only after the yes, as the user\'s own', async () => {
render(<WipeDeviceSection />);
fireEvent.click(screen.getByTestId('wipe-device-button'));
fireEvent.click(await screen.findByRole('button', { name: 'confirm_button' }));
await waitFor(() => expect(wipe.whole).toHaveBeenCalledWith('user'));
});
it('a no leaves everything', async () => {
render(<WipeDeviceSection />);
fireEvent.click(screen.getByTestId('wipe-device-button'));
await screen.findByTestId('wipe-device-details');
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
await waitFor(() => expect(screen.queryByTestId('wipe-device-details')).toBeNull());
expect(wipe.whole).not.toHaveBeenCalled();
});
});
+6
View File
@@ -13,7 +13,9 @@ import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offl
import { isOfflineNow, useOfflineStore } from '@/lib/offline/state';
import { minutesLeft, progressWorthShowing, type SyncProgress } from '@/lib/offline/sync-progress';
import { devicesForAccount } from '@/lib/device-policy';
import { localExportAllowed } from '@/components/mail-transfer/start-local-export';
import { useAccountStore } from '@/stores/account-store';
import { openLocalExport } from '@/stores/mail-transfer-store';
import { KeptFiles } from './kept-files';
const MB = 1024 * 1024;
@@ -65,6 +67,7 @@ function useUnsent(account: string) {
export function AccountStorage({ account, primary, heading }: { account: string; primary: boolean; heading: string | null }) {
const t = useTranslations('offline.settings');
const tStorage = useTranslations('offlineStorage');
const tExport = useTranslations('offlineExport');
const format = useFormatter();
const enabled = useOfflineStore((s) => s.enabled.includes(account));
const usage = useOfflineStore((s) => s.usage[account]);
@@ -220,6 +223,9 @@ export function AccountStorage({ account, primary, heading }: { account: string;
</SettingItem>
)}
<KeptFiles account={account} />
<SettingItem label={tExport('settings_label')} description={tExport('settings_description')} locked={!localExportAllowed(account)}>
<Button variant="outline" size="sm" disabled={!localExportAllowed(account)} onClick={() => openLocalExport(account)} data-testid={testId('local-export-open')}>{tExport('settings_button')}</Button>
</SettingItem>
<SettingItem label={tStorage('clear_label')} description={cantClear ? tStorage('clear_needs_connection') : tStorage('clear_description')}>
<Button variant="outline" size="sm" disabled={cantClear} onClick={askClear} data-testid={testId('offline-clear')}>{tStorage('clear_button')}</Button>
</SettingItem>
+49
View File
@@ -0,0 +1,49 @@
'use client';
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { consumeWipeNote, onRefusedLoginsChanged, takeSiblingToasts, waitDate, type WipeNote } from '@/lib/devices/wipe-state';
import { startDeviceWipe } from '@/lib/offline/device-wipe';
import { useAuthStore } from '@/stores/auth-store';
import { toast } from '@/stores/toast-store';
/** What a wipe left to say, as a sentence. Counts only. */
export function wipeNoteText(note: WipeNote, t: (key: string, values?: { count: number }) => string): string {
if (note.unknown) return t('note_unknown');
return note.unsent > 0 ? t('note_unsent', { count: note.unsent }) : t('note');
}
/**
* Runs the device's side of wipes on every page, the sign-in page too, so a
* device with nobody signed in still finishes a wipe and hears about a new one.
* When another account stays signed in, the note about lost mail is a toast here;
* otherwise the sign-in page shows it. Not mounted in Lite.
*/
export function DeviceWipeHost() {
const t = useTranslations('offlineWipe');
const tRef = useRef(t);
tRef.current = t;
const signedIn = useAuthStore((state) => state.isAuthenticated);
useEffect(() => startDeviceWipe(), []);
useEffect(() => {
if (!signedIn) return;
const note = consumeWipeNote();
if (note) toast.warning(wipeNoteText(note, tRef.current), { duration: 0 });
}, [signedIn]);
// An account beside this one signed out by itself: once, when it's known what waits.
useEffect(() => {
const look = () => {
for (const wait of takeSiblingToasts(useAuthStore.getState().activeAccountId)) {
const values = { name: wait.label, date: waitDate(wait.until) };
toast.warning(wait.unsent ? tRef.current('sibling_waiting', { ...values, count: wait.unsent }) : tRef.current('sibling_kept', values), { duration: 0 });
}
};
look();
return onRefusedLoginsChanged(look);
}, []);
return null;
}
+3 -1
View File
@@ -8,13 +8,14 @@ import { Button } from '@/components/ui/button';
import { IS_LITE } from '@/lib/lite';
import { isDesktopApp } from '@/lib/desktop/bridge';
import { signedInAccountIds } from '@/lib/device-policy';
import { offlineSupported, readPersistence, refreshUsage, requestPersistence, safariMayDelete } from '@/lib/offline/offline-mode';
import { offlineAllowed, offlineSupported, readPersistence, refreshUsage, requestPersistence, safariMayDelete } from '@/lib/offline/offline-mode';
import { useOfflineStore } from '@/lib/offline/state';
import { useDevicePolicy } from '@/hooks/use-device-policy';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { AccountStorage } from './account-storage';
import { OutboxList } from './outbox-list';
import { WipeDeviceSection } from './wipe-device-section';
/**
* Settings > Backup & reset > Offline (#523): for each account signed in here,
@@ -81,6 +82,7 @@ export function OfflineSettings() {
heading={several ? labels.find((entry) => entry.id === account)?.email || labels.find((entry) => entry.id === account)?.label || account : null}
/>
))}
{[active, ...accounts].some((account) => offlineAllowed(account)) && <WipeDeviceSection />}
</>
)}
<OutboxList />
@@ -0,0 +1,75 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { wipeWholeDevice } from '@/lib/offline/device-wipe';
import { unsentForWipe } from '@/lib/offline/offline-mode';
/** The question doesn't wait as long for the count as the wipe itself does. */
const QUESTION_WAIT_MS = 2_000;
/**
* "Remove everything from this device", for someone giving a computer away or
* selling it. It signs out, so the question says exactly what goes.
*/
export function WipeDeviceSection() {
const t = useTranslations('offlineWipe');
const [asking, setAsking] = useState(false);
// Null: the store couldn't say, which isn't the same as none.
const [unsent, setUnsent] = useState<number | null>(0);
const [wiping, setWiping] = useState(false);
const ask = async () => {
setUnsent(await unsentForWipe('all', QUESTION_WAIT_MS).catch(() => null));
setAsking(true);
};
const confirmed = () => {
setAsking(false);
setWiping(true);
void wipeWholeDevice('user').finally(() => setWiping(false));
};
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border py-3" data-testid="wipe-device-section">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{t('settings_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('settings_description')}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="text-destructive"
disabled={wiping}
onClick={() => { void ask(); }}
data-testid="wipe-device-button"
>
{wiping ? t('settings_wiping') : t('settings_button')}
</Button>
<ConfirmDialog
isOpen={asking}
onClose={() => setAsking(false)}
onConfirm={confirmed}
title={t('confirm_title')}
message={t('confirm_message')}
details={(
<ul className="list-disc space-y-1 ps-5 text-sm text-muted-foreground" data-testid="wipe-device-details">
<li>{t('confirm_mail')}</li>
{unsent === null && <li className="font-medium text-foreground">{t('confirm_unsent_unknown')}</li>}
{unsent !== null && unsent > 0 && <li className="font-medium text-foreground">{t('confirm_unsent', { count: unsent })}</li>}
<li>{t('confirm_keys')}</li>
<li>{t('confirm_plugins')}</li>
<li>{t('confirm_files')}</li>
<li>{t('confirm_templates')}</li>
<li>{t('confirm_accounts')}</li>
</ul>
)}
confirmText={t('confirm_button')}
variant="destructive"
/>
</div>
);
}
+1
View File
@@ -348,6 +348,7 @@ export function useAppSidePanel({ view, context, dock = true, hidden = false, on
aria-label={panelLabelOf(shownIds, t('plugin_panel'))}
data-app-side-panel={overlay ? 'overlay' : 'docked'}
data-app-side-panel-view={view}
data-window-end-column=""
className={cn(
'flex flex-col h-full border-s border-border bg-background overflow-hidden shrink-0',
overlay && 'absolute inset-y-0 end-0 z-30 shadow-xl',
@@ -142,6 +142,7 @@ export function PluginSidePanel() {
{active && (
<aside
data-plugin-side-panel={active.pluginId}
data-window-end-column=""
className="flex w-[300px] shrink-0 flex-col overflow-hidden border-s border-border bg-background"
aria-label={active.title}
>
@@ -150,6 +151,7 @@ export function PluginSidePanel() {
)}
{inRail.length > 0 && <nav
data-plugin-side-rail=""
data-window-end-column=""
className="flex w-12 shrink-0 flex-col items-center gap-1 border-s border-border bg-background pt-2"
aria-label={t('mv_side_panel')}
>
@@ -1,10 +1,12 @@
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel';
import { createComposeHandoff, rememberComposePayload } from '@/lib/popout/compose-handoff';
import { useAuthStore } from '@/stores/auth-store';
import { useIdentityStore } from '@/stores/identity-store';
import { usePluginStore } from '@/stores/plugin-store';
import { emailHooks } from '@/lib/plugin-hooks';
// The composer's window: when it tells the opening window to let go, and what
// happens on a reload. The composer itself is stubbed; its own tests cover it.
@@ -25,9 +27,29 @@ vi.mock('@/components/error', () => ({ ErrorBoundary: ({ children }: { children:
vi.mock('../popout-shell', () => ({
PopoutShell: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoutSkeleton: () => <div data-testid="skeleton" />,
PopoutNotice: ({ title, children }: { title: string; children: React.ReactNode }) => <div><h1>{title}</h1>{children}</div>,
}));
vi.mock('@/hooks/use-document-title', () => ({ useDocumentTitle: vi.fn() }));
const identityMocks = vi.hoisted(() => ({
multi: { enabled: false, allIdentities: [] as unknown[], groups: [] as unknown[] },
bump: undefined as undefined | (() => void),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', async () => {
const { useReducer } = await import('react');
return {
useProMultiAccountIdentities: () => {
const [, bump] = useReducer((n: number) => n + 1, 0);
identityMocks.bump = bump;
return identityMocks.multi;
},
stripCrossAccountIdentityPrefix: (id: string) => {
const at = id.indexOf('::');
return at >= 0 ? { localAccountId: id.slice(0, at), rawId: id.slice(at + 2) } : { localAccountId: null, rawId: id };
},
};
});
const { PopoutComposeApp } = await import('../popout-compose-app');
const original = useAuthStore.getState();
@@ -67,6 +89,153 @@ function openAt(handoffId: string) {
render(<PopoutComposeApp />);
}
describe('a composer coming back on its draft', () => {
const closeSelf = vi.fn();
const setRestore = vi.fn();
const LOGIN = 'alice@example.org@mail.example.org';
const MAILBOXES = [
{ id: 'mDrafts', role: 'drafts' },
{ id: 'mSent', role: 'sent' },
{ id: 'mTrash', role: 'trash' },
{ id: 'mInbox', role: 'inbox' },
];
beforeEach(() => {
closeSelf.mockClear();
setRestore.mockClear();
identityMocks.multi = { enabled: false, allIdentities: [], groups: [] };
useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never);
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf, setRestore } } as never;
});
afterEach(() => {
window.bulwarkDesktop = undefined;
vi.useRealTimers();
});
const draft = {
id: 'd1', subject: 'Half-written', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }],
keywords: { $draft: true }, mailboxIds: { mDrafts: true },
bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [{ blobId: 'b1', name: 'a.pdf', type: 'application/pdf', size: 3 }],
};
const openDraft = (getEmail: ReturnType<typeof vi.fn>, search = `draft=d1&account=${encodeURIComponent(LOGIN)}&owner=group`) => {
const client = { getEmail, getMailboxes: vi.fn(async () => MAILBOXES) };
useAuthStore.setState({ client: { other: true } as never, getClientForAccount: (id: string) => (id === LOGIN ? client : undefined) } as never);
window.history.replaceState(null, '', `/en/popout/compose?${search}`);
render(<PopoutComposeApp />);
return client;
};
it('reads the draft from where it lives, and opens the composer on it, id and all', async () => {
const getEmail = vi.fn(async () => draft);
openDraft(getEmail);
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Half-written'));
expect(getEmail).toHaveBeenCalledWith('d1', 'group');
// Saving goes over this draft, not next to it, and its files stay.
expect(composer?.initialData).toMatchObject({ draftId: 'd1', draftAccountId: 'group', draftLocalAccountId: LOGIN, to: 'a@example.org' });
expect((composer?.initialData as unknown as { attachments: unknown[] }).attachments).toHaveLength(1);
});
// Sending keeps the id. A composer on it would destroy the Sent copy at the next
// autosave, Send would mail it twice, and Discard would delete it.
it.each([
['a message that was sent', { keywords: { $seen: true }, mailboxIds: { mSent: true } }],
['a sent message still marked as a draft', { keywords: { $draft: true }, mailboxIds: { mSent: true } }],
['a draft in the Trash', { keywords: { $draft: true }, mailboxIds: { mTrash: true } }],
['mail in the Inbox that is no draft at all', { keywords: {}, mailboxIds: { mInbox: true } }],
])('%s is not opened', async (_name, shape) => {
openDraft(vi.fn(async () => ({ ...draft, ...shape })));
await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1));
expect(screen.queryByTestId('composer')).toBeNull();
});
it('a send brought back has no $draft, and in Drafts it opens', async () => {
openDraft(vi.fn(async () => ({ ...draft, keywords: {}, mailboxIds: { mDrafts: true } })));
await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
});
it('an address with no login opens nothing, not the active account\'s mail under the same id', async () => {
const getEmail = vi.fn(async () => draft);
useAuthStore.setState({ client: { getEmail, getMailboxes: async () => MAILBOXES } as never, getClientForAccount: () => undefined } as never);
window.history.replaceState(null, '', '/en/popout/compose?draft=d1');
render(<PopoutComposeApp />);
await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1));
expect(getEmail).not.toHaveBeenCalled();
});
it('a draft gone since closes the window', async () => {
openDraft(vi.fn(async () => null));
await waitFor(() => expect(closeSelf).toHaveBeenCalledTimes(1));
expect(screen.queryByTestId('composer')).toBeNull();
});
it('a server not answering keeps the window and the entry, and Try again works', async () => {
let up = false;
const getEmail = vi.fn(async () => {
if (!up) throw new Error('offline');
return draft;
});
openDraft(getEmail);
await screen.findByText('unavailable_title');
expect(closeSelf).not.toHaveBeenCalled();
// On the list for next time.
expect(setRestore.mock.lastCall![0]).toEqual([expect.stringContaining('draft=d1')]);
up = true;
fireEvent.click(screen.getByText('try_again'));
await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
});
it('plugins get their say on the draft, as when it opens from the list', async () => {
const hook = emailHooks.onBeforeEditDraft.register('test', ((value: unknown) => ({ ...(value as object), subject: 'Changed by a plugin' })) as never);
try {
openDraft(vi.fn(async () => draft));
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Changed by a plugin'));
} finally {
hook.dispose();
}
});
describe('with several accounts', () => {
const OTHER = { id: 'other@x@h::id-9', email: 'other@example.org', name: 'Other', localAccountId: LOGIN };
it('waits for the identity that wrote it, however late it loads, and starts on that', async () => {
identityMocks.multi = { enabled: true, allIdentities: [], groups: [] };
const fromOther = { ...draft, from: [{ name: 'Other', email: 'other@example.org' }] };
openDraft(vi.fn(async () => fromOther));
await settle();
// Not on the default in the meantime: it would save that.
expect(screen.queryByTestId('composer')).toBeNull();
act(() => {
identityMocks.multi = { enabled: true, allIdentities: [OTHER], groups: [] };
identityMocks.bump?.();
});
await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
expect(composer?.initialData).toMatchObject({ selectedIdentityId: OTHER.id });
expect((composer?.initialData as unknown as { fromOverrideEnabled?: boolean }).fromOverrideEnabled).toBeUndefined();
});
it('an address no identity has any more stays as the From, not the default', async () => {
identityMocks.multi = { enabled: true, allIdentities: [], groups: [] };
vi.useFakeTimers({ shouldAdvanceTime: true });
openDraft(vi.fn(async () => ({ ...draft, from: [{ name: 'Gone', email: 'gone@example.org' }] })));
await act(async () => {
await vi.advanceTimersByTimeAsync(4100);
});
await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
expect(composer?.initialData).toMatchObject({ selectedIdentityId: null, fromOverrideEnabled: true, fromOverrideEmail: 'gone@example.org', fromOverrideName: 'Gone' });
});
});
it('a save moves the address on, or a reload would look for a version that is gone', async () => {
openDraft(vi.fn(async () => draft));
await waitFor(() => expect(screen.getByTestId('composer')).toBeTruthy());
act(() => (composer as unknown as { ownWindow: { onDraftSaved: (id: string) => void } }).ownWindow.onDraftSaved('d2'));
expect(new URLSearchParams(window.location.search).get('draft')).toBe('d2');
expect(new URLSearchParams(window.location.search).get('account')).toBe(LOGIN);
});
});
describe('PopoutComposeApp', () => {
it('tells the opening window to let go only with the composer on screen', async () => {
const handoff = createComposeHandoff()!;
@@ -1,9 +1,10 @@
import { act, cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel';
import { setLockedHere } from '@/lib/app-lock/state';
import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel';
import { takeRescuedDraft } from '@/lib/popout/rescued-drafts';
import { createSessionOpener } from '@/lib/popout/sealed';
import { createSessionOpener, credentialFingerprint } from '@/lib/popout/sealed';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
@@ -121,6 +122,78 @@ describe('PopoutHost', () => {
expect(JSON.stringify(heard)).not.toContain('Bearer main');
});
it('gives no credentials while the app is locked, to a pop-out that never heard of the lock', async () => {
render(<PopoutHost />);
await settle();
setLockedHere(true);
const opener = await createSessionOpener();
popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey });
await settle();
expect(answers()).toEqual([]);
setLockedHere(false);
popout.post({ type: 'session-request', requestId: 'r2', publicKey: opener.publicKey });
await vi.waitFor(() => expect(answers()).toHaveLength(1));
expect(answers().map((message) => (message as { requestId: string }).requestId)).toEqual(['r2']);
});
it('renews no token for a pop-out while the app is locked', async () => {
const refresh = vi.fn(async () => 'next');
useAuthStore.setState({ refreshAccessToken: refresh as never });
render(<PopoutHost />);
await settle();
setLockedHere(true);
try {
const opener = await createSessionOpener();
const rejectedFingerprint = await credentialFingerprint('Bearer main');
popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey, accountId: 'alice', rejectedFingerprint });
await settle(40);
expect(refresh).not.toHaveBeenCalled();
expect(answers()).toEqual([]);
} finally {
setLockedHere(false);
}
});
it('answers nothing when the lock comes during the renewal', async () => {
const refresh = vi.fn(async () => {
setLockedHere(true);
return 'next';
});
useAuthStore.setState({ refreshAccessToken: refresh as never });
render(<PopoutHost />);
await settle();
try {
const opener = await createSessionOpener();
const rejectedFingerprint = await credentialFingerprint('Bearer main');
popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey, accountId: 'alice', rejectedFingerprint });
await vi.waitFor(() => expect(refresh).toHaveBeenCalled());
await settle(40);
expect(answers()).toEqual([]);
} finally {
setLockedHere(false);
}
});
it('a locked pop-out doesn\'t ask for a session', async () => {
setLockedHere(true);
await expect(requestWindowSession({}, 100)).resolves.toBeNull();
setLockedHere(false);
expect(heard.filter((message) => message.type === 'session-request')).toEqual([]);
});
it('a locked pop-out takes no session from the cookies either, and asks nobody', async () => {
const fetched = vi.fn(async () => new Response('{}'));
vi.stubGlobal('fetch', fetched);
setLockedHere(true);
try {
await expect(useAuthStore.getState().startSharedSession({ accountId: 'alice' })).resolves.toBe('no-session');
} finally {
setLockedHere(false);
}
expect(fetched).not.toHaveBeenCalled();
expect(useAuthStore.getState().client).not.toBeNull();
});
it('says nothing while it has no session to lend, so a pop-out goes on to the cookies', async () => {
useAuthStore.setState({ isAuthenticated: false });
render(<PopoutHost />);
@@ -78,6 +78,21 @@ afterEach(() => {
window.history.replaceState(null, '', '/');
});
describe('a message opened from a bulwark:// link', () => {
it('asks for nothing automatic: such a link may show a message, not act on it', async () => {
window.history.replaceState(null, '', '/en/popout/message/m%2F1?account=login-b&via=link');
render(<PopoutMessageApp id="m/1" />);
await settle();
expect((view as unknown as { noAutomaticActions?: boolean }).noAutomaticActions).toBe(true);
});
it('an ordinary pop-out acts as ever', async () => {
render(<PopoutMessageApp id="m/1" />);
await settle();
expect((view as unknown as { noAutomaticActions?: boolean }).noAutomaticActions).toBeFalsy();
});
});
describe('PopoutMessageApp', () => {
it('opens the message of the address, through its login and account', async () => {
render(<PopoutMessageApp id="rendered-for-another-id" />);
+141 -7
View File
@@ -18,7 +18,15 @@ import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watch
import { acknowledgeComposeHandoff, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff";
import type { ComposePopoutPayload } from "@/lib/popout/compose-payload";
import { closePopoutWindow } from "@/lib/popout/window-role";
import { PopoutShell, PopoutSkeleton } from "./popout-shell";
import { Button } from "@/components/ui/button";
import { composerDataFromDraft, isRestorableDraft } from "@/lib/draft-composer-data";
import { noteOpenDraft } from "@/lib/desktop/window-restore";
import { findDraftIdentityIdForLogin } from "@/lib/draft-source";
import { emailHooks } from "@/lib/plugin-hooks";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { useIdentityStore } from "@/stores/identity-store";
import type { Email } from "@/lib/jmap/types";
import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell";
/** A warning with no main window left to show it stays this long before the window goes. */
const WARNING_READ_MS = 8000;
@@ -31,10 +39,126 @@ const EMPTY_PAYLOAD: ComposePopoutPayload = { mode: "compose" };
/** How long a reloaded window waits to hear what became of the text it handed over on the way out. */
const RESCUE_ANSWER_MS = 2000;
function readLaunch(): { handoffId: string | null; accountId?: string } {
function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } {
if (typeof window === "undefined") return { handoffId: null };
const search = new URLSearchParams(window.location.search);
return { handoffId: search.get("handoff"), accountId: search.get("account") ?? undefined };
return {
handoffId: search.get("handoff"),
accountId: search.get("account") ?? undefined,
draftId: search.get("draft") ?? undefined,
ownerAccountId: search.get("owner") ?? undefined,
};
}
/** How long a restored draft waits for the identity that wrote it to load. */
const IDENTITY_WAIT_MS = 4000;
/**
* The composer that was open when the desktop app quit, back on its draft. A draft
* that has gone since (sent or deleted elsewhere) means there's nothing to show.
* A server that doesn't answer says nothing about it: the window stays, offers to try
* again, and the draft stays on the list for the next start.
*
* Only through the login the address names, and never without one: ids repeat across
* accounts, so another login could hold someone else's message under this id.
*
* The From stays what it was. With several accounts the other accounts' identities
* load later, and the composer would otherwise start on the default and save that.
*/
function RestoredDraft({ draftId, accountId, ownerAccountId, onReady }: {
draftId: string;
accountId?: string;
ownerAccountId?: string;
onReady: (payload: ComposePopoutPayload) => void;
}) {
const t = useTranslations();
const tRestore = useTranslations("desktopUi.restore");
const tWindows = useTranslations("pwaWindows");
const client = useAuthStore((s) => (accountId ? s.getClientForAccount(accountId) : undefined));
const ownIdentities = useIdentityStore((s) => s.identities);
const multi = useProMultiAccountIdentities();
const identities = multi.enabled ? multi.allIdentities : ownIdentities;
const [draft, setDraft] = useState<Email | null>(null);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
const [waitOver, setWaitOver] = useState(false);
// Until the composer takes over, this window still stands for the draft.
useEffect(() => {
const key = `restored-${draftId}`;
noteOpenDraft(key, { draftId, localAccountId: accountId, ownerAccountId });
return () => noteOpenDraft(key, null);
}, [draftId, accountId, ownerAccountId]);
useEffect(() => {
if (!accountId) {
closePopoutWindow();
return;
}
if (!client) return;
let cancelled = false;
setFailed(false);
void Promise.all([client.getEmail(draftId, ownerAccountId), client.getMailboxes(ownerAccountId)]).then(async ([found, mailboxes]) => {
if (cancelled) return;
if (!found || !isRestorableDraft(found, mailboxes)) {
closePopoutWindow();
return;
}
const transformed = await emailHooks.onBeforeEditDraft.transform(found);
if (!cancelled) setDraft(transformed);
}).catch((error) => {
debug.error("Failed to reopen a draft:", error);
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
};
}, [client, accountId, draftId, ownerAccountId, attempt]);
// Back online: try by itself.
useEffect(() => {
if (!failed) return;
const retry = () => setAttempt((n) => n + 1);
window.addEventListener("online", retry);
return () => window.removeEventListener("online", retry);
}, [failed]);
useEffect(() => {
const timer = setTimeout(() => setWaitOver(true), IDENTITY_WAIT_MS);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!draft) return;
const from = draft.from?.[0];
const matched = findDraftIdentityIdForLogin(identities, from, accountId);
// The identity may still be on its way: wait for it, then keep the address as it was.
if (!matched && from && !waitOver) return;
const data = composerDataFromDraft(draft, {
accountId: ownerAccountId,
localAccountId: accountId,
identities,
noSubject: t("email_composer.no_subject"),
});
onReady({
mode: "compose",
initialData: !matched && from?.email
? { ...data, fromOverrideEnabled: true, fromOverrideEmail: from.email, fromOverrideName: from.name ?? "" }
: data,
});
// onReady swaps this component out; `t` changes nothing here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft, identities, waitOver]);
if (failed) {
return (
<PopoutNotice title={tRestore("unavailable_title")} body={tRestore("unavailable_body")}>
<Button onClick={() => setAttempt((n) => n + 1)}>{tRestore("try_again")}</Button>
<Button variant="ghost" onClick={closePopoutWindow}>{tRestore("close")}</Button>
</PopoutNotice>
);
}
return <PopoutSkeleton label={tWindows("opening")} />;
}
/** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */
@@ -153,7 +277,15 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
onUnloadState: (state: ComposerDraftData) => {
if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null });
},
onDraftSaved: () => announceMailChanged(useAuthStore.getState().activeAccountId),
onDraftSaved: (draftId: string) => {
announceMailChanged(useAuthStore.getState().activeAccountId);
// A save makes a new id, and this address is what a reload starts from.
const url = new URL(window.location.href);
if (url.searchParams.has("draft")) {
url.searchParams.set("draft", draftId);
window.history.replaceState(null, "", `${url.pathname}${url.search}`);
}
},
}), [handoffId, mainWindow, payload]);
return (
@@ -181,8 +313,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
/** `/popout/compose`: a draft handed over by another window, or a new message. */
export function PopoutComposeApp() {
const t = useTranslations("pwaWindows");
const [{ handoffId, accountId }] = useState(readLaunch);
const [payload, setPayload] = useState<ComposePopoutPayload | null>(handoffId ? null : EMPTY_PAYLOAD);
const [{ handoffId, accountId, draftId, ownerAccountId }] = useState(readLaunch);
const [payload, setPayload] = useState<ComposePopoutPayload | null>(handoffId || draftId ? null : EMPTY_PAYLOAD);
useEffect(() => {
if (!handoffId) return;
@@ -216,7 +348,9 @@ export function PopoutComposeApp() {
return (
<PopoutShell kind="compose" accountId={accountId}>
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} /> : <PopoutSkeleton label={t("opening")} />}
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} />
: draftId ? <RestoredDraft draftId={draftId} accountId={accountId} ownerAccountId={ownerAccountId} onReady={setPayload} />
: <PopoutSkeleton label={t("opening")} />}
</PopoutShell>
);
}
+4 -1
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { isAppLockedNow } from "@/lib/app-lock/state";
import { usePopoutsAvailable } from "@/hooks/use-popouts-available";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
@@ -76,6 +77,8 @@ export function PopoutHost() {
const answerSessionRequest = async (message: Extract<PopoutMessage, { type: "session-request" }>) => {
const { accountId, rejectedFingerprint } = message;
// Locked: nobody gets a credential, whatever they ask.
if (isAppLockedNow()) return;
const auth = useAuthStore.getState();
// Only a window holding the login answers: the first answer has to be usable.
if (accountId && !shareableSessions().some((session) => session.accountId === accountId)) return;
@@ -86,7 +89,7 @@ export function PopoutHost() {
if (current && await credentialFingerprint(current) === rejectedFingerprint) await auth.refreshAccessToken({ accountId });
}
const sessions = shareableSessions();
if (sessions.length === 0) return;
if (sessions.length === 0 || isAppLockedNow()) return;
const sealed = await sealFor(message.publicKey, { activeAccountId: useAuthStore.getState().activeAccountId, sessions });
channel?.post({ type: "session-answer", requestId: message.requestId, sealed });
};
+5 -4
View File
@@ -24,7 +24,7 @@ const CHECK_INTERVAL_MS = 60_000;
* The id from the address bar: a page the service worker kept for a start
* without the network was rendered for another id.
*/
function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string } {
function readLaunch(paramId: string): { emailId: string; accountId?: string; ownerAccountId?: string; fromLink?: boolean } {
if (typeof window === "undefined") return { emailId: paramId };
const segments = window.location.pathname.split("/").filter(Boolean);
const at = segments.lastIndexOf("message");
@@ -35,7 +35,7 @@ function readLaunch(paramId: string): { emailId: string; accountId?: string; own
// A malformed address: the route's own value.
}
const search = new URLSearchParams(window.location.search);
return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined };
return { emailId, accountId: search.get("account") ?? undefined, ownerAccountId: search.get("owner") ?? undefined, fromLink: search.get("via") === "link" };
}
type Presence = "present" | "gone";
@@ -96,7 +96,7 @@ function useMessagePresence(emailId: string, ownerAccountId: string | undefined)
return { presence, mailboxIds, liveState };
}
function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string; accountId?: string; ownerAccountId?: string }) {
function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { emailId: string; accountId?: string; ownerAccountId?: string; fromLink?: boolean }) {
const t = useTranslations("pwaWindows");
const tSidebar = useTranslations("sidebar");
const login = useAuthStore((s) => accountId ?? s.activeAccountId ?? undefined);
@@ -194,6 +194,7 @@ function PopoutMessage({ emailId, accountId, ownerAccountId }: { emailId: string
onOpenCompose={openCompose}
liveState={liveState}
onMailChanged={handleMailChanged}
noAutomaticActions={fromLink}
className="min-h-0 w-full flex-1"
/>
</>
@@ -205,7 +206,7 @@ export function PopoutMessageApp({ id }: { id: string }) {
const [launch] = useState(() => readLaunch(id));
return (
<PopoutShell kind="message" accountId={launch.accountId}>
<PopoutMessage emailId={launch.emailId} accountId={launch.accountId} ownerAccountId={launch.ownerAccountId} />
<PopoutMessage emailId={launch.emailId} accountId={launch.accountId} ownerAccountId={launch.ownerAccountId} fromLink={launch.fromLink} />
</PopoutShell>
);
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { withBasePath } from "@/lib/browser-navigation";
import { PopoutComposeApp } from "./popout-compose-app";
import { PopoutMessageApp } from "./popout-message-app";
/** The message id a pop-out address names. */
function messageIdOf(pathname: string): string {
const segments = pathname.split("/").filter(Boolean);
const at = segments.lastIndexOf("message");
return at >= 0 && segments[at + 1] ? decodeURIComponent(segments[at + 1]) : "";
}
/**
* The desktop app's warm spare: a hidden window with the pop-out code loaded and
* nothing signed in. When a pop-out is wanted the shell gives it an address. The
* address bar changes without a load, which is where the pop-outs read their
* arguments from, and the right one renders in a window that's already up.
*/
export function PopoutSpareApp() {
const [target, setTarget] = useState<URL | null>(null);
useEffect(() => {
const spare = getDesktopBridge()?.windows?.spare;
if (!spare) {
// Someone typed the address, or a browser followed a link.
window.location.replace(withBasePath("/"));
return;
}
const stop = spare.onActivate((url) => {
const next = new URL(url, window.location.href);
window.history.replaceState(null, "", `${next.pathname}${next.search}`);
setTarget(next);
spare.taken();
});
spare.ready();
return stop;
}, []);
if (!target) return <div data-popout-spare="" className="h-dvh bg-background" />;
if (/\/popout\/compose\/?$/.test(target.pathname)) return <PopoutComposeApp />;
return <PopoutMessageApp id={messageIdOf(target.pathname)} />;
}
+29 -1
View File
@@ -34,6 +34,8 @@ import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
import { canDownload } from '@/lib/device-policy';
import { useOpenEmailInWindow } from '@/hooks/use-popouts-available';
import { isFilePreviewable } from '@/lib/file-preview';
import { useDesktopCommands } from '@/lib/desktop/commands';
import { isPopoutWindow } from '@/lib/popout/window-role';
import dynamic from 'next/dynamic';
// Only needed when the device policy turns downloads off.
@@ -77,6 +79,8 @@ interface ProEmailViewProps {
liveState?: Pick<Email, 'keywords' | 'mailboxIds'> | null;
/** This view changed the message's flags, for a host other windows should hear from. */
onMailChanged?: () => void;
/** See EmailViewer. */
noAutomaticActions?: boolean;
/**
* The message left this view: deleted, archived, moved, or reopened in the
* composer. The host decides what that means (close the tab / clear the
@@ -132,7 +136,7 @@ function buildReplyContext(email: Email): ProReplyContext {
* Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's
* reading pane.
*/
export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, onClose, className }: ProEmailViewProps) {
export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, onClose, className }: ProEmailViewProps) {
const t = useTranslations();
const tNotifications = useTranslations('notifications');
const tRemove = useTranslations('email_list.confirm_remove');
@@ -450,6 +454,29 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
setEmail({ ...email, keywords });
}, [email, setEmailKeywordsLocal]);
// In a pop-out this message is all there is, so the menu bar's Message menu means it.
const inOwnWindow = typeof window !== 'undefined' && isPopoutWindow();
const seen = !!email?.keywords?.$seen;
useDesktopCommands({
'message.reply': () => handleReply(),
'message.replyAll': handleReplyAll,
'message.forward': handleForward,
'message.markRead': () => { if (email) void handleMarkAsRead(email.id, true); },
'message.markUnread': () => { if (email) void handleMarkAsRead(email.id, false); },
'message.star': () => void handleToggleStar(),
'message.archive': archiveActionEnabled ? () => void handleArchive() : undefined,
'message.delete': () => void handleDelete(),
}, {
'message.reply': !!email,
'message.replyAll': !!email,
'message.forward': !!email,
'message.markRead': !!email && !seen,
'message.markUnread': !!email && seen,
'message.star': !!email,
'message.archive': !!email && archiveActionEnabled,
'message.delete': !!email,
}, inOwnWindow);
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
if (!client || !email) return;
try {
@@ -549,6 +576,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
<div className={cn("flex h-full flex-col bg-background", className)}>
<ErrorBoundary fallback={EmailViewerErrorFallback}>
<EmailViewer
noAutomaticActions={noAutomaticActions}
email={email}
blobSource={blobSource}
isLoading={isLoading}
+1
View File
@@ -228,6 +228,7 @@ export function ProTabBar({
style={{ borderBottom: '1px solid rgba(128, 128, 128, 0.3)' }}
role="tablist"
data-pane-strip={paneId}
data-window-top-row
onDragLeave={handleStripDragLeave}
>
{tabs.map((tab, index) => {
@@ -7,6 +7,7 @@ import { usePathname, useRouter } from "@/i18n/navigation";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfig } from "@/hooks/use-config";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { startOsCommands } from "@/lib/desktop/os-commands";
import { flushComposers } from "@/lib/compose-flush-registry";
import { isPopoutPath } from "@/lib/popout/window-role";
import { isStandaloneDisplayMode } from "@/lib/standalone-display";
@@ -110,6 +111,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
const router = useRouter();
const pathname = usePathname();
const tLaunch = useTranslations("pwaLaunch");
const tDesktop = useTranslations("desktopOs");
const sentToTarget = useRef(new Set<string>());
const { dialogProps, confirm } = useConfirmDialog();
const [offer, setOffer] = useState<ShareOffer | null>(null);
@@ -201,6 +203,22 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
return subscribeHandoff("any", goToTarget);
}, [pathname, leaveFor]);
// The desktop app: what the OS asks for arrives over the bridge, not launchQueue.
const osEnv = useRef({ leaveFor, push: router.push as (path: string) => void, pathname });
osEnv.current = { leaveFor, push: router.push as (path: string) => void, pathname };
useEffect(() => {
if (isPopoutPath(window.location.pathname)) return;
return startOsCommands({
leaveFor: (path) => osEnv.current.leaveFor(path),
push: (path) => {
if (osEnv.current.pathname !== path) osEnv.current.push(path);
},
notifyFailed: () => toast.error(tLaunch("open_failed")),
notifyReplyLost: () => toast.error(tDesktop("reply_lost")),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return;
@@ -1,12 +1,15 @@
"use client";
import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog';
import { useCalendarAlerts } from '@/hooks/use-calendar-alerts';
import { useStoreReminders } from '@/hooks/use-store-reminders';
import { ToastContainer } from '@/components/ui/toast';
import { LiveRegions } from '@/components/ui/live-regions';
import { useToastStore } from '@/stores/toast-store';
export function CalendarAlertProvider({ children }: { children: React.ReactNode }) {
useCalendarAlerts();
useStoreReminders();
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
@@ -15,6 +18,7 @@ export function CalendarAlertProvider({ children }: { children: React.ReactNode
<>
{children}
<ToastContainer toasts={toasts} onClose={removeToast} />
<MissedRemindersDialog />
<LiveRegions />
</>
);
@@ -184,7 +184,7 @@ describe('session timeout guard', () => {
await advance(500);
expect(order).toEqual(['draft saved', 'signed out']);
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 });
});
@@ -196,7 +196,7 @@ describe('session timeout guard', () => {
await advance(DRAFT_FLUSH_TIMEOUT_MS - 1);
expect(logoutAll).not.toHaveBeenCalled();
await advance(1);
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
});
it('uses the strictest of the signed-in accounts', async () => {
@@ -258,7 +258,7 @@ describe('session timeout guard', () => {
expect(screen.queryByText('max_title')).toBeNull();
await advance(60 * SECOND);
await advance(0);
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 1 });
});
@@ -276,7 +276,7 @@ describe('session timeout guard', () => {
fireEvent.pointerDown(window);
await advance(0);
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 });
});
@@ -319,7 +319,7 @@ describe('session timeout guard', () => {
// Later in the body than the overlay, so it paints on top at a higher z-index too.
expect(overlay.compareDocumentPosition(warning) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
await advance(30 * SECOND);
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
} finally {
overlay.remove();
hasFocus.mockRestore();
+4 -2
View File
@@ -92,7 +92,8 @@ const IDLE_STATE: SessionTimerState = { phase: "active", kind: null, deadline: n
* (policy.json `organisation.session`). Activity in any Bulwark tab keeps
* every tab signed in; a warning counts down first; at zero the open and
* minimised composers save their drafts and every account signs out, with
* the reason on the login page.
* the reason on the login page. What the accounts keep on the device hides until
* the next sign-in, unless the organisation asks for deletion at once.
*/
export function SessionTimeoutGuard() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
@@ -149,7 +150,8 @@ export function SessionTimeoutGuard() {
rememberSessionEnd(endReason(kind, current, false));
const flushed = await flushComposers(DRAFT_FLUSH_TIMEOUT_MS);
if (flushed.failed > 0 || flushed.timedOut) rememberSessionEnd(endReason(kind, current, true));
await useAuthStore.getState().logoutAll({ endProviderSession: false });
// Nobody asked: the mail hides until the next sign-in, and unsent messages with it.
await useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true });
}, []);
// The clock, while there is a limit to count.
@@ -0,0 +1,189 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { DesktopCrashFile, DesktopCrashSendResult, DesktopPrivacyState } from '@/lib/desktop/types';
import { DesktopTelemetrySettings } from '../desktop-telemetry-settings';
// The desktop app's two switches. A browser sees nothing. The shell decides and asks; this
// page only shows what it says, and sends a crash file only when the button is pressed.
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
vi.mock('next-intl', async (importOriginal) => ({
...(await importOriginal<typeof import('next-intl')>()),
useTranslations: () => (key: string) => key,
useFormatter: () => ({ dateTime: (date: Date) => date.toISOString() }),
}));
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
const OFF: DesktopPrivacyState = {
telemetry: { on: false, forcedOff: false },
crashReports: { on: false, forcedOff: false, uploadUrl: '' },
};
const CRASH_ON: DesktopPrivacyState = { ...OFF, crashReports: { on: true, forcedOff: false, uploadUrl: '' } };
const FILE: DesktopCrashFile = { id: 'a1', createdAt: Date.parse('2026-11-03T10:00:00Z'), bytes: 2048 };
function bridge(state: DesktopPrivacyState, crashes: DesktopCrashFile[] = [], mode: 'standalone' | 'instance' = 'standalone') {
const privacy = {
get: vi.fn(async () => state),
setTelemetry: vi.fn(async (on: boolean) => ({ ...state, telemetry: { ...state.telemetry, on } })),
setCrashReports: vi.fn(async (on: boolean) => ({ ...state, crashReports: { ...state.crashReports, on } })),
setCrashUploadUrl: vi.fn(async (_url: string) => ({ ok: true, state })),
listCrashes: vi.fn(async () => crashes),
sendCrash: vi.fn(async (_id: string): Promise<DesktopCrashSendResult> => ({ ok: true })),
deleteCrashes: vi.fn(async () => {}),
onChange: vi.fn(() => () => {}),
};
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode, privacy };
return privacy;
}
afterEach(() => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
toast.success.mockClear();
toast.error.mockClear();
});
const checked = (id: string) => screen.getByTestId(id).getAttribute('aria-checked');
describe('usage info and crash reports', () => {
it('shows nothing in a browser', async () => {
const { container } = render(<DesktopTelemetrySettings />);
await new Promise((resolve) => setTimeout(resolve, 5));
expect(container.textContent).toBe('');
});
it('shows nothing under a shell without the bridge member', async () => {
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' };
const { container } = render(<DesktopTelemetrySettings />);
await new Promise((resolve) => setTimeout(resolve, 5));
expect(container.textContent).toBe('');
});
it('starts with both off, and says what a crash file can hold', async () => {
bridge(OFF);
render(<DesktopTelemetrySettings />);
await screen.findByTestId('desktop-telemetry-toggle');
expect(checked('desktop-telemetry-toggle')).toBe('false');
expect(checked('desktop-crash-toggle')).toBe('false');
expect(screen.getByText('crash_memory')).toBeTruthy();
});
it('asks the shell and shows its answer', async () => {
const privacy = bridge(OFF);
render(<DesktopTelemetrySettings />);
fireEvent.click(await screen.findByTestId('desktop-telemetry-toggle'));
await waitFor(() => expect(privacy.setTelemetry).toHaveBeenCalledWith(true));
await waitFor(() => expect(checked('desktop-telemetry-toggle')).toBe('true'));
expect(privacy.setCrashReports).not.toHaveBeenCalled();
});
it('shows a switch still off when the user said no in the shell\'s dialog', async () => {
const privacy = bridge(OFF);
privacy.setCrashReports.mockResolvedValue(OFF);
render(<DesktopTelemetrySettings />);
fireEvent.click(await screen.findByTestId('desktop-crash-toggle'));
await waitFor(() => expect(privacy.setCrashReports).toHaveBeenCalledWith(true));
expect(checked('desktop-crash-toggle')).toBe('false');
});
it('locks both when a policy forces them off, and says who did', async () => {
const privacy = bridge({
telemetry: { on: false, forcedOff: true },
crashReports: { on: false, forcedOff: true, uploadUrl: '' },
});
render(<DesktopTelemetrySettings />);
const toggle = await screen.findByTestId('desktop-telemetry-toggle');
expect(toggle.hasAttribute('disabled')).toBe(true);
expect(screen.getByTestId('desktop-crash-toggle').hasAttribute('disabled')).toBe(true);
expect(screen.getAllByText('forced_off')).toHaveLength(2);
fireEvent.click(toggle);
expect(privacy.setTelemetry).not.toHaveBeenCalled();
});
it('adds the note about the instance\'s own admin only on an instance', async () => {
bridge(OFF, [], 'instance');
render(<DesktopTelemetrySettings />);
await screen.findByTestId('desktop-telemetry-toggle');
expect(screen.getByText(/instance_note/)).toBeTruthy();
});
});
describe('crash files', () => {
it('sends only when the button is pressed', async () => {
const privacy = bridge(CRASH_ON, [FILE]);
render(<DesktopTelemetrySettings />);
const send = await screen.findByTestId('desktop-crash-send');
expect(privacy.sendCrash).not.toHaveBeenCalled();
fireEvent.click(send);
await waitFor(() => expect(privacy.sendCrash).toHaveBeenCalledWith('a1'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('sent'));
});
it('says so when no address is set', async () => {
const privacy = bridge(CRASH_ON, [FILE]);
privacy.sendCrash.mockResolvedValue({ ok: false, reason: 'no-url' });
render(<DesktopTelemetrySettings />);
fireEvent.click(await screen.findByTestId('desktop-crash-send'));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('send_no_url'));
});
it('says nothing when the user said no in the dialog', async () => {
const privacy = bridge(CRASH_ON, [FILE]);
privacy.sendCrash.mockResolvedValue({ ok: false, reason: 'cancelled' });
render(<DesktopTelemetrySettings />);
fireEvent.click(await screen.findByTestId('desktop-crash-send'));
await waitFor(() => expect(privacy.sendCrash).toHaveBeenCalled());
await new Promise((resolve) => setTimeout(resolve, 5));
expect(toast.error).not.toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
});
it('asks for the address only with crash reports on, and shows a bad one\'s answer', async () => {
const privacy = bridge(CRASH_ON);
privacy.setCrashUploadUrl.mockResolvedValue({ ok: false, state: CRASH_ON });
render(<DesktopTelemetrySettings />);
const input = await screen.findByTestId('desktop-crash-url');
fireEvent.change(input, { target: { value: 'ftp://nope' } });
fireEvent.click(screen.getByRole('button', { name: 'url_save' }));
await waitFor(() => expect(privacy.setCrashUploadUrl).toHaveBeenCalledWith('ftp://nope'));
await screen.findByText('url_invalid');
});
it('hides the address with crash reports off', async () => {
bridge(OFF);
render(<DesktopTelemetrySettings />);
await screen.findByTestId('desktop-telemetry-toggle');
expect(screen.queryByTestId('desktop-crash-url')).toBeNull();
});
it('deletes through the shell', async () => {
const privacy = bridge(CRASH_ON, [FILE]);
render(<DesktopTelemetrySettings />);
fireEvent.click(await screen.findByRole('button', { name: 'delete_all' }));
await waitFor(() => expect(privacy.deleteCrashes).toHaveBeenCalledTimes(1));
});
});
describe('the text', () => {
const en = JSON.parse(readFileSync(path.join(process.cwd(), 'locales/en/common.json'), 'utf8')).desktopTelemetry as Record<string, string>;
it('names everything a heartbeat holds, in plain words', () => {
for (const word of [/a random ID/, /app version/, /Node version/, /operating system/, /how long the app has been running/, /how many plugins and themes/]) {
expect(en.telemetry_description).toMatch(word);
}
expect(en.telemetry_description).not.toMatch(/uptime/i);
});
it('says crash reports carry a random ID too', () => {
expect(en.crash_description).toMatch(/a random ID/);
});
it('says a crash file can still hold the text of mail after scrubbing', () => {
expect(en.crash_memory).toMatch(/can still contain the text of your mail/);
});
});
+206
View File
@@ -0,0 +1,206 @@
"use client";
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { useTranslations } from "next-intl";
import { CurrentPinField, NewPinFields, newPinReady } from "@/components/app-lock/pin-fields";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { useConfig } from "@/hooks/use-config";
import {
addOsUnlock,
addPasskey,
changeAppLockPin,
disableAppLock,
enableAppLock,
hasPasskey,
lockEverywhere,
removePasskey,
setAppLockTimeout,
} from "@/lib/app-lock/controller";
import { passkeysPossible } from "@/lib/app-lock/passkey";
import { appLockRequired, effectiveTimeout, onLockPolicyChange } from "@/lib/app-lock/policy";
import { readAppLockFile, TIMEOUT_CHOICES } from "@/lib/app-lock/state";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopOsUnlockKind } from "@/lib/desktop/types";
import { IS_LITE } from "@/lib/lite";
import { toast } from "@/stores/toast-store";
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
type Pending = "enable" | "disable" | "change" | "passkey" | "os" | null;
function useLockFacts() {
const [, refresh] = useState(0);
const update = useCallback(() => refresh((n) => n + 1), []);
useEffect(() => onLockPolicyChange(update), [update]);
const file = readAppLockFile();
return { on: file.on, required: appLockRequired(), timeout: effectiveTimeout(file.timeoutMinutes), update };
}
/** The app lock's section on the General page: it has to exist on any server. */
export function AppLockSettings() {
const t = useTranslations("appLock");
const { appName } = useConfig();
const { on, required, timeout, update } = useLockFacts();
const [pending, setPending] = useState<Pending>(null);
const [pin, setPin] = useState("");
const [repeat, setRepeat] = useState("");
const [current, setCurrent] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [passkey, setPasskey] = useState(false);
const [os, setOs] = useState<{ kind: DesktopOsUnlockKind; enrolled: boolean } | null>(null);
const refreshMethods = useCallback(() => {
void hasPasskey().then(setPasskey).catch(() => {});
void getDesktopBridge()?.appLock?.status()
.then((status) => setOs(status.os ? { kind: status.os, enrolled: status.osEnrolled } : null))
.catch(() => {});
}, []);
useEffect(refreshMethods, [refreshMethods, on]);
const close = () => {
setPending(null);
setPin("");
setRepeat("");
setCurrent("");
setError(null);
setBusy(false);
};
const describeFailure = (failure: unknown): string => {
const name = (failure as Error)?.name;
if (name === "WrongPinError") return t("wrong_pin");
if (name === "PinBlockedError") return t("blocked_later");
if (name === "PasskeyNoPrfError") return t("passkey_no_prf");
if (name === "ObviousPinError") return t("pin_obvious");
return t("save_failed");
};
const run = async (work: () => Promise<void>) => {
setBusy(true);
setError(null);
try {
await work();
close();
update();
refreshMethods();
} catch (failure) {
setBusy(false);
setError(describeFailure(failure));
}
};
const submit = (event: FormEvent) => {
event.preventDefault();
if (busy) return;
if (pending === "enable" && newPinReady(pin, repeat)) void run(() => enableAppLock(pin));
if (pending === "change" && current && newPinReady(pin, repeat)) void run(() => changeAppLockPin(current, pin));
if (pending === "disable" && current) void run(() => disableAppLock(current));
if (pending === "passkey" && current) {
void run(async () => {
await addPasskey(current, appName);
toast.success(t("passkey_added"));
});
}
if (pending === "os" && current) {
void run(async () => {
if (!(await addOsUnlock(current))) throw new Error("Not set up");
});
}
};
if (IS_LITE) return null;
const osName = os?.kind === "touchid" ? t("use_touchid_name") : t("use_hello_name");
const asksNew = pending === "enable" || pending === "change";
const asksCurrent = pending === "disable" || pending === "change" || pending === "passkey" || pending === "os";
const ready = (!asksNew || newPinReady(pin, repeat)) && (!asksCurrent || current.length > 0);
const titles: Record<Exclude<Pending, null>, string> = {
enable: t("set_pin_title"),
change: t("change_pin_title"),
disable: t("disable_title"),
passkey: t("passkey_add_title"),
os: t("os_add_title", { method: osName }),
};
return (
<SettingsSection id="app-lock" title={t("section_title")} description={t("section_desc")}>
<SettingItem label={t("enable_label")} description={t("enable_desc")} locked={required}>
<ToggleSwitch checked={on || required} onChange={(next) => setPending(next ? "enable" : "disable")} testId="app-lock-switch" />
</SettingItem>
<p className="text-xs text-muted-foreground" data-testid="app-lock-protects">{t("protects_desc")}</p>
{on && (
<>
<SettingItem label={t("timeout_label")} description={t("timeout_desc")} locked={timeout.locked}>
<select
data-testid="app-lock-timeout"
className="h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground"
value={timeout.minutes}
onChange={(event) => {
setAppLockTimeout(Number(event.target.value));
update();
}}
>
{[...new Set([...TIMEOUT_CHOICES, timeout.minutes])].sort((a, b) => a - b).map((minutes) => (
<option key={minutes} value={minutes}>{t("timeout_option", { count: minutes })}</option>
))}
</select>
</SettingItem>
<SettingItem label={t("lock_now")} description={t("lock_now_desc")}>
<Button variant="outline" size="sm" className="text-xs h-7" onClick={lockEverywhere} data-testid="app-lock-now">{t("lock_now")}</Button>
</SettingItem>
<SettingItem label={t("pin_label")} description={t("pin_recovery")}>
<Button variant="outline" size="sm" className="text-xs h-7" onClick={() => setPending("change")}>{t("change_pin")}</Button>
</SettingItem>
{passkeysPossible() && (
<SettingItem label={t("passkey_label")} description={t("passkey_desc")}>
{passkey ? (
<Button variant="outline" size="sm" className="text-xs h-7" onClick={() => void removePasskey().then(refreshMethods)}>{t("passkey_remove")}</Button>
) : (
<Button variant="outline" size="sm" className="text-xs h-7" onClick={() => setPending("passkey")} data-testid="app-lock-add-passkey">{t("passkey_add")}</Button>
)}
</SettingItem>
)}
{os && (
<SettingItem label={osName} description={t("os_desc", { method: osName })}>
{os.enrolled ? (
<Button variant="outline" size="sm" className="text-xs h-7" onClick={() => void getDesktopBridge()?.appLock?.osRemove().then(refreshMethods)}>{t("os_remove")}</Button>
) : (
<Button variant="outline" size="sm" className="text-xs h-7" onClick={() => setPending("os")}>{t("os_add")}</Button>
)}
</SettingItem>
)}
</>
)}
<Dialog
isOpen={pending !== null}
onClose={close}
title={pending ? titles[pending] : ""}
description={pending === "disable" ? t("disable_body") : pending === "enable" ? t("enable_body") : undefined}
onSubmit={submit}
dirty={pin.length > 0 || current.length > 0}
testId="app-lock-dialog"
footer={(
<>
<Button type="button" variant="outline" onClick={close}>{t("cancel")}</Button>
<Button type="submit" disabled={busy || !ready} data-testid="app-lock-dialog-save">
{pending === "disable" ? t("disable_confirm") : t("save")}
</Button>
</>
)}
>
<div className="space-y-3">
{asksCurrent && <CurrentPinField value={current} onChange={setCurrent} error={error} />}
{asksNew && <NewPinFields pin={pin} repeat={repeat} onPin={setPin} onRepeat={setRepeat} />}
{!asksCurrent && error && <p role="alert" className="text-sm text-destructive">{error}</p>}
</div>
</Dialog>
</SettingsSection>
);
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopOsDefaultAppStatus, DesktopOsSettings as Settings, DesktopOsStatus } from "@/lib/desktop/types";
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
/** On Windows and Linux this is what a press of the keys shows; macOS maps it to Cmd. */
const SHOW_SHORTCUT = "CommandOrControl+Shift+B";
function shortcutLabel(platform: string | undefined): string {
return platform === "darwin" ? "⇧⌘B" : "Ctrl+Shift+B";
}
/**
* The desktop app's own settings, on the General page: only where there's an app to
* set. A browser never sees it, and it renders nothing until the bridge has answered.
* Quick compose adds its shortcut here later.
*/
export function DesktopOsSettings() {
const t = useTranslations("desktopOs");
const [status, setStatus] = useState<DesktopOsStatus | null>(null);
const [defaultApp, setDefaultApp] = useState<DesktopOsDefaultAppStatus | null>(null);
const bridge = getDesktopBridge();
const os = bridge?.os;
useEffect(() => {
if (!os) return;
let stale = false;
void os.getStatus().then((next) => {
if (!stale) setStatus(next);
});
void os.defaultAppStatus().then((next) => {
if (!stale) setDefaultApp(next);
});
const stop = os.onStatusChange(setStatus);
return () => {
stale = true;
stop();
};
}, [os]);
if (!os || !status) return null;
const { settings } = status;
const change = (patch: Partial<Settings>) => {
void os.setSettings(patch).then(setStatus);
};
return (
<SettingsSection id="desktop-app" title={t("section_title")} description={t("section_description")}>
<SettingItem label={t("close_to_tray_label")} description={t("close_to_tray_description")}>
<ToggleSwitch checked={settings.closeToTray} onChange={(closeToTray) => change({ closeToTray })} />
</SettingItem>
<SettingItem label={t("start_at_login_label")} description={t("start_at_login_description")}>
<ToggleSwitch checked={settings.startAtLogin} onChange={(startAtLogin) => change({ startAtLogin })} />
</SettingItem>
{settings.startAtLogin && (
<SettingItem nested label={t("start_minimized_label")} description={t("start_minimized_description")}>
<ToggleSwitch checked={settings.startMinimized} onChange={(startMinimized) => change({ startMinimized })} />
</SettingItem>
)}
<SettingItem
label={bridge?.platform === "darwin" ? t("attention_mac_label") : t("attention_label")}
description={t("attention_description")}
>
<ToggleSwitch checked={settings.attentionOnNewMail} onChange={(attentionOnNewMail) => change({ attentionOnNewMail })} />
</SettingItem>
<SettingItem label={t("quiet_hours_label")} description={t("quiet_hours_description")}>
<ToggleSwitch checked={settings.quietHoursEnabled} onChange={(quietHoursEnabled) => change({ quietHoursEnabled })} />
</SettingItem>
{settings.quietHoursEnabled && (
<SettingItem nested label={t("quiet_hours_from")}>
<div className="flex items-center gap-2">
<input
type="time"
aria-label={t("quiet_hours_from")}
value={settings.quietHoursStart}
onChange={(event) => change({ quietHoursStart: event.target.value })}
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
/>
<span className="text-sm text-muted-foreground">{t("quiet_hours_to")}</span>
<input
type="time"
aria-label={t("quiet_hours_to")}
value={settings.quietHoursEnd}
onChange={(event) => change({ quietHoursEnd: event.target.value })}
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
/>
</div>
</SettingItem>
)}
<SettingItem
label={t("show_shortcut_label")}
description={settings.showShortcut && !status.shortcutActive
? t("show_shortcut_taken", { keys: shortcutLabel(bridge?.platform) })
: t("show_shortcut_description", { keys: shortcutLabel(bridge?.platform) })}
>
<ToggleSwitch
checked={!!settings.showShortcut}
onChange={(on) => change({ showShortcut: on ? SHOW_SHORTCUT : null })}
/>
</SettingItem>
<SettingItem
label={t("default_app_label")}
description={defaultApp?.mailto ? t("default_app_is_default") : t("default_app_description")}
>
<Button
variant="outline"
size="sm"
onClick={() => void os.openDefaultAppSettings().then(setDefaultApp)}
className="text-xs h-7"
>
{t("default_app_button")}
</Button>
</SettingItem>
</SettingsSection>
);
}
@@ -0,0 +1,173 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useFormatter, useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopCrashFile, DesktopCrashSendResult, DesktopPrivacyState } from "@/lib/desktop/types";
import { toast } from "@/stores/toast-store";
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
const SEND_ERROR: Record<Exclude<DesktopCrashSendResult, { ok: true }>["reason"], string | null> = {
"no-consent": "send_no_consent",
"no-url": "send_no_url",
"not-found": "send_failed",
failed: "send_failed",
// The user said no in the dialog: nothing to tell them.
cancelled: null,
};
/**
* Usage info and crash reports in the desktop app: each with its own switch, both off
* until the user says yes, and only where there's an app. A browser never sees this, and
* it renders nothing until the bridge has answered. The shell does the deciding, the
* asking and the sending.
*/
export function DesktopTelemetrySettings() {
const t = useTranslations("desktopTelemetry");
const format = useFormatter();
const bridge = getDesktopBridge();
const privacy = bridge?.privacy;
const [state, setState] = useState<DesktopPrivacyState | null>(null);
const [crashes, setCrashes] = useState<DesktopCrashFile[]>([]);
const [url, setUrl] = useState("");
const [urlInvalid, setUrlInvalid] = useState(false);
const [sending, setSending] = useState<string | null>(null);
const refreshCrashes = useCallback(() => {
void privacy?.listCrashes().then(setCrashes, () => {});
}, [privacy]);
useEffect(() => {
if (!privacy) return;
let stale = false;
const apply = (next: DesktopPrivacyState) => {
if (stale) return;
setState(next);
setUrl(next.crashReports.uploadUrl);
};
void privacy.get().then(apply, () => {});
refreshCrashes();
const stop = privacy.onChange((next) => {
apply(next);
refreshCrashes();
});
return () => {
stale = true;
stop();
};
}, [privacy, refreshCrashes]);
if (!privacy || !state) return null;
const { telemetry, crashReports } = state;
const saveUrl = async () => {
const result = await privacy.setCrashUploadUrl(url.trim());
setUrlInvalid(!result.ok);
if (result.ok) setState(result.state);
};
const send = async (id: string) => {
setSending(id);
try {
const result = await privacy.sendCrash(id);
if (result.ok) toast.success(t("sent"));
else if (SEND_ERROR[result.reason]) toast.error(t(SEND_ERROR[result.reason]!));
} finally {
setSending(null);
}
};
const showCrashFiles = crashReports.on || crashes.length > 0;
return (
<SettingsSection id="desktop-privacy" title={t("section_title")} description={t("section_description")}>
<SettingItem
label={t("telemetry_label")}
description={telemetry.forcedOff ? t("forced_off") : t("telemetry_description")}
locked={telemetry.forcedOff}
>
<ToggleSwitch
checked={telemetry.on}
disabled={telemetry.forcedOff}
testId="desktop-telemetry-toggle"
onChange={(on) => void privacy.setTelemetry(on).then(setState)}
/>
</SettingItem>
<SettingItem
label={t("crash_label")}
description={crashReports.forcedOff ? t("forced_off") : t("crash_description")}
locked={crashReports.forcedOff}
>
<ToggleSwitch
checked={crashReports.on}
disabled={crashReports.forcedOff}
testId="desktop-crash-toggle"
onChange={(on) => void privacy.setCrashReports(on).then((next) => {
setState(next);
refreshCrashes();
})}
/>
</SettingItem>
{!crashReports.forcedOff && (
<p className="py-2 text-xs text-muted-foreground">
{t("crash_memory")}
{bridge?.mode === "instance" && ` ${t("instance_note")}`}
</p>
)}
{crashReports.on && (
<SettingItem nested label={t("url_label")} description={urlInvalid ? t("url_invalid") : t("url_description")}>
<div className="flex items-center gap-2">
<Input
type="url"
value={url}
placeholder="https://crash.example.org/upload"
data-testid="desktop-crash-url"
onChange={(event) => {
setUrl(event.target.value);
setUrlInvalid(false);
}}
className="w-64"
/>
<Button variant="outline" size="sm" onClick={() => void saveUrl()}>{t("url_save")}</Button>
</div>
</SettingItem>
)}
{showCrashFiles && (
<SettingItem nested label={t("files_label")} description={t("files_description")}>
{crashes.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => void privacy.deleteCrashes().then(refreshCrashes)}
>
{t("delete_all")}
</Button>
)}
</SettingItem>
)}
{showCrashFiles && crashes.length === 0 && (
<p className="ms-4 ps-3 py-2 text-xs text-muted-foreground">{t("files_empty")}</p>
)}
{crashes.map((crash) => (
<SettingItem
key={crash.id}
nested
label={format.dateTime(new Date(crash.createdAt), { dateStyle: "medium", timeStyle: "short" })}
description={t("file_size", { size: Math.max(1, Math.round(crash.bytes / 1024)) })}
>
<Button
variant="outline"
size="sm"
disabled={sending !== null}
data-testid="desktop-crash-send"
onClick={() => void send(crash.id)}
>
{t("send")}
</Button>
</SettingItem>
))}
</SettingsSection>
);
}
@@ -0,0 +1,26 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { hasDesktopWindows } from "@/lib/popout/support";
import { setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences";
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
/** How the desktop app's windows behave. Only there: a browser has nothing to set. */
export function DesktopWindowSettings() {
const t = useTranslations("desktopUi.settings");
const prefs = useDesktopPrefs();
const [shown, setShown] = useState(false);
useEffect(() => setShown(hasDesktopWindows()), []);
if (!shown) return null;
return (
<SettingsSection id="desktop-windows" title={t("title")}>
<SettingItem label={t("double_click_label")} description={t("double_click_description")}>
<ToggleSwitch checked={prefs.openOnDoubleClick} onChange={(checked) => setDesktopPref("openOnDoubleClick", checked)} />
</SettingItem>
<SettingItem label={t("restore_label")} description={t("restore_description")}>
<ToggleSwitch checked={prefs.restoreWindows} onChange={(checked) => setDesktopPref("restoreWindows", checked)} />
</SettingItem>
</SettingsSection>
);
}

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