mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
Merge branch 'main' into feat/do-packaging
# Conflicts: # components/settings/general-settings.tsx # lib/desktop/types.ts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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]')} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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't raise what another one lowered. The app holds itself to
|
||||
them when it fetches the policy; the server can't check what is on a device. 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'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();
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user