diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 992bef2ec..9fadc2052 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -27,7 +27,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 +39,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"; @@ -85,10 +89,13 @@ export default async function LocaleLayout({ + {!IS_LITE && } {!IS_LITE && } + {!IS_LITE && } + {!IS_LITE && } {children} @@ -115,6 +122,7 @@ export default async function LocaleLayout({ {/* Offline mode isn't in Lite (no service worker there). */} {!IS_LITE && } + {!IS_LITE && } {!IS_LITE && } diff --git a/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx new file mode 100644 index 000000000..de689d054 --- /dev/null +++ b/app/(main)/[locale]/login/__tests__/login-page-desktop-policy.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge } from '@/lib/desktop/types'; +import { EMPTY_POLICY_LAYERS, resolvePolicies } from '@/lib/desktop/policy-schema'; + +// The device's OS policy on the two ways into SSO: nothing starts towards a refused server. + +vi.mock('next/navigation', () => ({ + useParams: () => ({ locale: 'en' }), + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', +})); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', + Link: ({ children }: { children: React.ReactNode }) => children, +})); + +const configOverrides = vi.hoisted(() => ({ value: {} as Record })); + +vi.mock('@/hooks/use-config', () => ({ + useConfig: () => ({ + appName: 'Webmail', + isLoading: false, + error: null, + jmapServerUrl: 'https://mail.example.org', + jmapServers: [], + oauthEnabled: true, + oauthOnly: true, + oauthClientId: 'webmail', + ...configOverrides.value, + }), +})); + +const { default: LoginPage } = await import('../page'); + +const METADATA = { + issuer: 'https://mail.example.org', + authorization_endpoint: 'https://mail.example.org/authorize', + token_endpoint: 'https://mail.example.org/token', +}; + +function allowOnly(...servers: string[]) { + const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { AllowedJmapServers: servers } }); + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + mode: 'standalone', + policy: { get: () => snapshot, onChange: () => () => {} }, + } as unknown as BulwarkDesktopBridge; +} + +let fetched: string[]; + +beforeEach(() => { + configOverrides.value = {}; + fetched = []; + sessionStorage.clear(); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + fetched.push(url); + if (url.includes('/api/auth/oauth/metadata')) return new Response(JSON.stringify(METADATA), { status: 200 }); + if (url.includes('/api/auth/sso/start')) return new Response(JSON.stringify({ authorize_url: 'about:blank#sso' }), { status: 200 }); + return new Response('{}', { status: 200 }); + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + window.bulwarkDesktop = undefined; +}); + +const ssoButton = () => screen.findByRole('button', { name: /sign_in_sso/ }, { timeout: 10000 }); +const startedPkce = () => sessionStorage.getItem('oauth_code_verifier'); +const startedSso = () => fetched.some((url) => url.includes('/api/auth/sso/start')); + +describe('the SSO button', () => { + it('starts nothing towards a server off the list, and says why', async () => { + allowOnly('https://other.example.org'); + render(); + fireEvent.click(await ssoButton()); + expect(await screen.findByText('server_refused')).toBeInTheDocument(); + expect(startedPkce()).toBeNull(); + }, 30_000); + + it('starts as usual towards a server on the list', async () => { + allowOnly('https://mail.example.org'); + render(); + fireEvent.click(await ssoButton()); + await waitFor(() => expect(startedPkce()).not.toBeNull()); + expect(screen.queryByText('server_refused')).toBeNull(); + }, 30_000); +}); + +describe('automatic SSO', () => { + it('asks the server for nothing when its server is off the list, and says why', async () => { + configOverrides.value = { autoSsoEnabled: true }; + allowOnly('https://other.example.org'); + render(); + expect(await screen.findByText('server_refused', {}, { timeout: 10000 })).toBeInTheDocument(); + expect(startedSso()).toBe(false); + }, 30_000); + + it('starts as usual when its server is on the list', async () => { + configOverrides.value = { autoSsoEnabled: true }; + allowOnly('https://mail.example.org'); + render(); + await waitFor(() => expect(startedSso()).toBe(true), { timeout: 10000 }); + }, 30_000); +}); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index bd29046e7..de5aa6ef1 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -14,6 +14,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 +200,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 +305,16 @@ function LoginPageContent() { const [sessionEnd, setSessionEnd] = useState(null); // Offline outbox messages a sign-out deleted unsent (#523), named once. const [outboxLost, setOutboxLost] = useState([]); + // Unsent mail a wipe took with it: how many, never which. + const [wipeLost, setWipeLost] = useState(null); + // Unsent mail that waits for a new password: how many and until when, never which. + const [refusedWaiting, setRefusedWaiting] = useState([]); useEffect(() => { setOutboxLost(consumeLost()); + setWipeLost(consumeWipeNote()); + const read = () => setRefusedWaiting(refusedWaits()); + read(); + return onRefusedLoginsChanged(read); }, []); const [expiredDraftsUnsaved, setExpiredDraftsUnsaved] = useState(false); const [showPassword, setShowPassword] = useState(false); @@ -640,7 +651,7 @@ function LoginPageContent() { const startServerSideSso = useCallback(async () => { // The device's OS policy, as for a password: nothing starts towards a refused server. - if (selectedServer?.url && !jmapServerAllowedByDesktopPolicy(selectedServer.url, desktopPolicy)) { + if (serverUrl && !jmapServerAllowedByDesktopPolicy(serverUrl, desktopPolicy)) { setDesktopPolicyNotice(tDesktopPolicy("server_refused")); return; } @@ -700,7 +711,7 @@ function LoginPageContent() { } catch { setOauthLoading(false); } - }, [params.locale, selectedServer?.id, selectedServer?.url, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]); + }, [params.locale, selectedServer?.id, serverUrl, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]); useEffect(() => { // After signing out, show the login page rather than signing straight @@ -1174,6 +1185,12 @@ function LoginPageContent() { // has to pick which one SSO targets before the button starts the flow, since // the selection drives OAuth discovery and the server_id sent to the callback // (issue #799). + const desktopPolicyAlert = desktopPolicyNotice && ( + + {desktopPolicyNotice} + + ); + const serverPicker = hasServerList && jmapServers.length > 1 ? ( @@ -1245,7 +1262,7 @@ function LoginPageContent() { return ( {/* Theme toggle, hidden while the theme policy locks the mode (#1280) */} - + {/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */} - + + {wipeLost !== null && ( + + {wipeNoteText(wipeLost, tOfflineWipe)} + + )} + {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 ( + + {wait.unsent === null ? tOfflineWipe(`${prefix}_unknown`, { date }) : tOfflineWipe(prefix, { count: wait.unsent, date })} + + ); + })} {outboxLost.length > 0 && ( {tOffline("signout.lost", { subjects: outboxLost.map((s) => s || tOffline("outbox.no_subject")).join(", ") })} @@ -1588,6 +1619,7 @@ function LoginPageContent() { /* OAuth-only mode: server picker (if any) plus the SSO button */ {serverPicker} + {desktopPolicyAlert} {oauthMetadata && showRememberMe && rememberMeBox} {oauthMetadata ? ( )} - {desktopPolicyNotice && ( - - {desktopPolicyNotice} - - )} + {desktopPolicyAlert} {IS_DESKTOP_BUILD && desktopNotice && ( {desktopNotice} diff --git a/app/(main)/[locale]/popout/spare/page.tsx b/app/(main)/[locale]/popout/spare/page.tsx new file mode 100644 index 000000000..1eafe6639 --- /dev/null +++ b/app/(main)/[locale]/popout/spare/page.tsx @@ -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 ; +} diff --git a/app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx b/app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx new file mode 100644 index 000000000..535f76f13 --- /dev/null +++ b/app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx @@ -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 => ({ + 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>; + +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(await screen.findByTestId('devices-empty')).toBeTruthy(); + }); +}); diff --git a/app/(main)/admin/_pages/device-registry-panel.tsx b/app/(main)/admin/_pages/device-registry-panel.tsx new file mode 100644 index 000000000..eba321350 --- /dev/null +++ b/app/(main)/admin/_pages/device-registry-panel.tsx @@ -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(null); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [query, setQuery] = useState(''); + const [search, setSearch] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [pending, setPending] = useState(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) => { + 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(); + 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 ( + + { event.preventDefault(); setOffset(0); setSearch(query.trim()); }} + > + 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" + /> + Search + + {error && {error}} + {devices !== null && devices.length === 0 && ( + + {search ? 'No device matches that search.' : 'No device has registered yet.'} + + )} + + {groups.map((group) => { + const wipeable = group.rows.filter((device) => !device.wipe && !device.wipedAt); + return ( + + + + {group.user} + {group.server} + + {wipeable.length > 1 && ( + setPending({ kind: 'user', userKey: group.userKey, user: group.user, count: wipeable.length })} + data-testid="devices-wipe-user" + > + Wipe all {wipeable.length} devices + + )} + + + {group.rows.map((device) => ( + Wipe waiting + : device.wipedAt + ? Wiped + : 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={( + + {!device.wipe && !device.wipedAt && ( + setPending({ kind: 'device', device })} + data-testid="device-wipe" + > + Wipe + + )} + {device.wipe && ( + { void act(device.id, { action: 'cancel', id: device.id }); }} + data-testid="device-cancel" + > + Take back + + )} + setPending({ kind: 'forget', device })} + data-testid="device-forget" + > + Forget + + + )} + /> + ))} + + + ); + })} + + {total > PAGE_SIZE && ( + + {offset + 1} to {Math.min(offset + PAGE_SIZE, total)} of {total} + + setOffset(Math.max(0, offset - PAGE_SIZE))}>Previous + = total} onClick={() => setOffset(offset + PAGE_SIZE)}>Next + + + )} + 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" + /> + + ); +} + +function wipeReasonLabel(reason: NonNullable): 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.`; +} diff --git a/app/(main)/admin/_pages/logs.tsx b/app/(main)/admin/_pages/logs.tsx index 4077e9732..d6930eeb2 100644 --- a/app/(main)/admin/_pages/logs.tsx +++ b/app/(main)/admin/_pages/logs.tsx @@ -63,6 +63,10 @@ export function LogsPage() { Config Update Config Revert Policy Update + Device Wipe Requested + Device Wipe Confirmed + Device Wipe Cancelled + Device Forgotten diff --git a/app/(main)/admin/_pages/org-devices.tsx b/app/(main)/admin/_pages/org-devices.tsx index a7cf2834d..c542cffe8 100644 --- a/app/(main)/admin/_pages/org-devices.tsx +++ b/app/(main)/admin/_pages/org-devices.tsx @@ -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() { + ); } diff --git a/app/(main)/admin/layout.tsx b/app/(main)/admin/layout.tsx index c666fd358..4ba067930 100644 --- a/app/(main)/admin/layout.tsx +++ b/app/(main)/admin/layout.tsx @@ -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 + {children} diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index e2704bcc8..e259ae6ad 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -20,7 +20,7 @@ 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"; @@ -334,7 +334,7 @@ export default async function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} ${hankenGrotesk.variable} antialiased`} > - + {!themeColorConfigured && } diff --git a/app/api/admin/devices/__tests__/auth.test.ts b/app/api/admin/devices/__tests__/auth.test.ts new file mode 100644 index 000000000..77cb78375 --- /dev/null +++ b/app/api/admin/devices/__tests__/auth.test.ts @@ -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()); +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): 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(); + }); +}); diff --git a/app/api/admin/devices/route.ts b/app/api/admin/devices/route.ts new file mode 100644 index 000000000..8e0ac47d4 --- /dev/null +++ b/app/api/admin/devices/route.ts @@ -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 { + 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 { + 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 }); + } +} diff --git a/app/api/devices/__tests__/route.test.ts b/app/api/devices/__tests__/route.test.ts new file mode 100644 index 000000000..055485d39 --- /dev/null +++ b/app/api/devices/__tests__/route.test.ts @@ -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(), + 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 = {}): 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; 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}"`); + } + }); +}); diff --git a/app/api/devices/route.ts b/app/api/devices/route.ts new file mode 100644 index 000000000..3d3be4c91 --- /dev/null +++ b/app/api/devices/route.ts @@ -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; + try { + body = JSON.parse(text) as Record; + } 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 }); + } +} diff --git a/app/api/devices/wiped/route.ts b/app/api/devices/wiped/route.ts new file mode 100644 index 000000000..31c10e2b3 --- /dev/null +++ b/app/api/devices/wiped/route.ts @@ -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; + try { + body = JSON.parse(text) as Record; + } 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 }); + } +} diff --git a/app/api/errors/__tests__/route.test.ts b/app/api/errors/__tests__/route.test.ts index d53e1dde6..c32f23499 100644 --- a/app/api/errors/__tests__/route.test.ts +++ b/app/api/errors/__tests__/route.test.ts @@ -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); + }); +}); diff --git a/app/api/errors/route.ts b/app/api/errors/route.ts index b31a38952..0bdb02235 100644 --- a/app/api/errors/route.ts +++ b/app/api/errors/route.ts @@ -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 }); diff --git a/app/api/plugin-secrets/__tests__/wipe.test.ts b/app/api/plugin-secrets/__tests__/wipe.test.ts new file mode 100644 index 000000000..075138ac9 --- /dev/null +++ b/app/api/plugin-secrets/__tests__/wipe.test.ts @@ -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 = { 'sec-fetch-site': 'same-origin' }): Promise { + 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' }]); + }); +}); diff --git a/app/api/plugin-secrets/route.ts b/app/api/plugin-secrets/route.ts index e235b5a0c..b767e0dbe 100644 --- a/app/api/plugin-secrets/route.ts +++ b/app/api/plugin-secrets/route.ts @@ -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'); diff --git a/app/desktop.css b/app/desktop.css index 7f1b55c74..49ca879bc 100644 --- a/app/desktop.css +++ b/app/desktop.css @@ -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; diff --git a/app/globals.css b/app/globals.css index 212b080c2..30a67a4e3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); diff --git a/app/manifest.ts b/app/manifest.ts index c95478c26..41f989ba2 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -126,7 +126,7 @@ export default async function manifest(): Promise { 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, diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index 857ab630b..0c2ffeaf7 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -442,6 +442,10 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, + {/* Only for the window's controls: the sidebar says which page is open. */} + + {pageName ?? 'Admin'} + {readOnlyBanner} diff --git a/components/admin/org/__tests__/offline-section.test.tsx b/components/admin/org/__tests__/offline-section.test.tsx index a5051d4fb..2b5630630 100644 --- a/components/admin/org/__tests__/offline-section.test.tsx +++ b/components/admin/org/__tests__/offline-section.test.tsx @@ -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')); diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index 359648627..9a806ccbc 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -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 ( draft.clearOrgValue('devices', 'noOfflineAttachments')} /> + + + draft.setOrgValue('devices', 'wipeOnForcedSignOut', v)} testId="org-devices-wipeOnForcedSignOut" /> + draft.clearOrgValue('devices', 'wipeOnForcedSignOut')} /> + + 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.{' '} How offline mode works diff --git a/components/boot-skeleton/admin-skeleton.tsx b/components/boot-skeleton/admin-skeleton.tsx index 16bc29d97..0db2ff49b 100644 --- a/components/boot-skeleton/admin-skeleton.tsx +++ b/components/boot-skeleton/admin-skeleton.tsx @@ -227,6 +227,9 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla + + + diff --git a/components/boot-skeleton/calendar-skeleton.tsx b/components/boot-skeleton/calendar-skeleton.tsx index 28191a02c..87011e717 100644 --- a/components/boot-skeleton/calendar-skeleton.tsx +++ b/components/boot-skeleton/calendar-skeleton.tsx @@ -176,7 +176,7 @@ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) { const views = text?.views; return ( <> - + diff --git a/components/boot-skeleton/files-skeleton.tsx b/components/boot-skeleton/files-skeleton.tsx index 840ec325e..6802ccd98 100644 --- a/components/boot-skeleton/files-skeleton.tsx +++ b/components/boot-skeleton/files-skeleton.tsx @@ -199,7 +199,7 @@ function FilesSidebarSkeleton() { function FilesHeaderSkeleton({ picker }: { picker: boolean }) { const on = "bg-primary/10 text-primary"; return ( - + {picker ? null : } diff --git a/components/boot-skeleton/login-skeleton.tsx b/components/boot-skeleton/login-skeleton.tsx index c36e4ef8d..28141c548 100644 --- a/components/boot-skeleton/login-skeleton.tsx +++ b/components/boot-skeleton/login-skeleton.tsx @@ -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. */} - + diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx index 063d040ba..a13165166 100644 --- a/components/boot-skeleton/mail-skeleton.tsx +++ b/components/boot-skeleton/mail-skeleton.tsx @@ -411,7 +411,7 @@ export function MailMessageToolbarSkeleton({ end }: { end?: ReactNode } = {}) { const button = "h-8 w-20 rounded-md"; return ( - + {/* Phones: back, then icon-over-label actions. */} diff --git a/components/boot-skeleton/pro-skeleton.tsx b/components/boot-skeleton/pro-skeleton.tsx index 3e6eecda0..6065e72d5 100644 --- a/components/boot-skeleton/pro-skeleton.tsx +++ b/components/boot-skeleton/pro-skeleton.tsx @@ -34,6 +34,8 @@ function TabStripSkeleton() { return ( diff --git a/components/boot-skeleton/settings-skeleton.tsx b/components/boot-skeleton/settings-skeleton.tsx index 7d8c868ec..f9d73e546 100644 --- a/components/boot-skeleton/settings-skeleton.tsx +++ b/components/boot-skeleton/settings-skeleton.tsx @@ -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 ( - + @@ -270,6 +270,11 @@ export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean + {embedded ? null : ( + + + + )} diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 75fb07763..40c372390 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -225,7 +225,7 @@ export function CalendarToolbar({ {/* ── MOBILE TOOLBAR ── */} {isMobile && ( - + {/* Row 1 while searching: the search box with a way back. */} {mobileSearchField ? ( diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 57fad15a2..94438b553 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -1037,7 +1037,7 @@ export function EventModal({ {/* Header */} - + diff --git a/components/calendar/missed-reminders-dialog.tsx b/components/calendar/missed-reminders-dialog.tsx new file mode 100644 index 000000000..2b4ed8af3 --- /dev/null +++ b/components/calendar/missed-reminders-dialog.tsx @@ -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 ( + + { + finish(); + window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; + }} + > + {t("open_calendar")} + + {t("done")} + > + )} + > + + {alerts.map((alert) => ( + + + {alert.title || tAlert("alert_title")} + + {format.format(new Date(alert.startMs ?? alert.fireTimeMs))} + {alert.calendarName ? ` · ${alert.calendarName}` : ""} + + + {alert.joinUrl && ( + void joinMeeting(alert.joinUrl!)} + > + {joinLabel(alert, joinLabels)} + + )} + + ))} + + + ); +} diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index a23cfd610..75ea6439a 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -222,7 +222,7 @@ export function TaskModal({ onKeyDown={handleKeyDown} > {/* Header */} - + {isEdit ? t("tasks.edit") : t("tasks.create")} diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 2b0b382d3..2f6b44f0d 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -99,7 +99,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli if (!contact) { return ( - + + {/* The window's controls lie over this end, and its empty top is something to drag by. */} + {t("detail.no_contact_selected")} @@ -139,7 +141,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli return ( - + diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index d35baf1af..dbd29960a 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -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. - + {isEditing ? t("edit_title") : t("create_title")} diff --git a/components/contacts/contact-group-detail.tsx b/components/contacts/contact-group-detail.tsx index 68d99cf85..48a251453 100644 --- a/components/contacts/contact-group-detail.tsx +++ b/components/contacts/contact-group-detail.tsx @@ -48,7 +48,7 @@ export function ContactGroupDetail({ return ( - + diff --git a/components/contacts/contact-group-form.tsx b/components/contacts/contact-group-form.tsx index c1c11278f..58e5b8bce 100644 --- a/components/contacts/contact-group-form.tsx +++ b/components/contacts/contact-group-form.tsx @@ -88,7 +88,7 @@ export function ContactGroupForm({ return ( - + {isEditing ? t("groups.edit") : t("groups.create")} diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx new file mode 100644 index 000000000..9f6da218b --- /dev/null +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -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)[] })); +vi.mock('@/lib/sign-out-cleanup', async (original) => ({ + ...(await original()), + onFullSignOut: (task: () => void) => signOut.full.push(task), + onBeforeSignOut: (task: () => Promise) => signOut.before.push(task), +})); + +const { DesktopHost } = await import('../desktop-host'); + +function fakeBridge() { + let command: ((id: string) => void) | null = null; + let flush: (() => Promise) | 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) => { 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()).not.toThrow(); + }); + + it('runs a command from what\'s on the page first', () => { + const shell = fakeBridge(); + render(); + 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(); + 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(); + shell.run('link.folder:sent'); + expect(push).toHaveBeenLastCalledWith('/mail/folder/sent'); + }); + + it('an unknown command does nothing', () => { + const shell = fakeBridge(); + render(); + 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(); + 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(); + 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(); + await act(async () => {}); + expect(shell.sessionReady).not.toHaveBeenCalled(); + first.unmount(); + useAuthStore.setState({ isAuthenticated: true } as never); + window.history.replaceState(null, '', '/popout/compose'); + render(); + await act(async () => {}); + expect(shell.sessionReady).not.toHaveBeenCalled(); + }); + + it('passes the restore setting on', async () => { + const shell = fakeBridge(); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await act(async () => {}); + expect(first.allowDownload).not.toHaveBeenCalled(); + view.unmount(); + setDesktopPref('dictionaryDownload', 'ask'); + const shell = fakeBridge(); + render(); + 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(); + 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(); + 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(); + act(() => useOfflineStore.setState({ outbox: [{ id: 1 }, { id: 2 }] } as never)); + await expect(shell.flush()).resolves.toEqual({ unsent: 2 }); + act(() => useOfflineStore.setState({ outbox: [] } as never)); + }); +}); diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx new file mode 100644 index 000000000..f8295a3ad --- /dev/null +++ b/components/desktop/desktop-host.tsx @@ -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 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(); + 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; +} diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx new file mode 100644 index 000000000..a6e522136 --- /dev/null +++ b/components/desktop/desktop-os-host.tsx @@ -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(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(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; +} diff --git a/components/device/__tests__/device-gates.test.tsx b/components/device/__tests__/device-gates.test.tsx index bb86a6b7b..5557f2eed 100644 --- a/components/device/__tests__/device-gates.test.tsx +++ b/components/device/__tests__/device-gates.test.tsx @@ -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'); }); }); diff --git a/components/device/device-policy-host.tsx b/components/device/device-policy-host.tsx index d7465be08..acfaa9c7c 100644 --- a/components/device/device-policy-host.tsx +++ b/components/device/device-policy-host.tsx @@ -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 }); diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index 8ac5e5b82..9ef9370a7 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -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(); diff --git a/components/email/__tests__/reader-attachments.test.tsx b/components/email/__tests__/reader-attachments.test.tsx index 8e0f1fae4..67c6913e7 100644 --- a/components/email/__tests__/reader-attachments.test.tsx +++ b/components/email/__tests__/reader-attachments.test.tsx @@ -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 { + afterEach(() => { + window.bulwarkDesktop = undefined; + cleanup(); + }); + + const openMenu = async (popupAnswer: (items: { id?: string; label?: string }[]) => string | null, overrides: Partial[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>)[handler]).toHaveBeenCalledTimes(1)); + expect((props as unknown as Record>)[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']); diff --git a/components/email/__tests__/viewer-mark-read.test.tsx b/components/email/__tests__/viewer-mark-read.test.tsx index 86e0f4dd8..569b0c285 100644 --- a/components/email/__tests__/viewer-mark-read.test.tsx +++ b/components/email/__tests__/viewer-mark-read.test.tsx @@ -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(); + 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(); diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx index 759465667..fd95d5e73 100644 --- a/components/email/__tests__/viewer-send-failures.test.tsx +++ b/components/email/__tests__/viewer-send-failures.test.tsx @@ -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(); + 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(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 44a474b99..d584a8c44 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -169,8 +169,12 @@ 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 { 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 +387,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,6 +1947,14 @@ 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; @@ -2698,6 +2710,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(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(null); const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId); @@ -3017,6 +3035,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 +3117,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; @@ -3360,6 +3380,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 +4458,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 +4492,7 @@ export function EmailComposer({ return; } afterCloseRef.current = null; - setCloseDialogKind('discard'); - setShowCloseDialog(true); + openCloseDialog('discard'); }; const handleClose = (afterClose?: () => void) => { @@ -4457,8 +4504,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 +4790,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} > diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 4f431dd60..092ca7f82 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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) => void; onDragEnd: (e: React.DragEvent) => void; + onCopyAsFile?: () => Promise; }) => React.ReactNode; } @@ -685,6 +692,19 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl const source = useMemo(() => ({ 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(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( - + + {/* An empty reading pane's top is still the window's top, to drag by. */} + diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx index 0e3aa1fbd..167767905 100644 --- a/components/email/reader-attachments.tsx +++ b/components/email/reader-attachments.tsx @@ -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) => void; onDragEnd: (e: DragEvent) => void; + /** The desktop app only. */ + onCopyAsFile?: () => Promise; } /** Past this many tiles the bar shows one row less and a "Show N more" tile. */ @@ -47,6 +53,11 @@ interface ReaderAttachmentsProps { layout?: ReaderAttachmentLayout; /** Object URLs of image attachments, keyed by attachment id. */ thumbUrls: Record; + /** + * 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({ onDownload, onDownloadAll, onSaveAllToFolder, + onCopyFile, downloadingAll, downloadsOff = false, onSaveToFiles, @@ -105,6 +117,11 @@ export function ReaderAttachments({ const tFiles = useTranslations("files"); const tDevice = useTranslations("device_policy"); const tWindows = useTranslations("pwaWindows"); + const tDesktop = useTranslations("desktopUi.attachment"); + const menu = useContextMenu(); + 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({ )} {pluginMenu?.(attachment)} + {!downloadsOff && drag.onCopyAsFile && } {!downloadsOff && ( ({ )} {pluginMenu?.(attachment)} + {!downloadsOff && drag.onCopyAsFile && } {!downloadsOff && ( onDownload(attachment)}> @@ -292,7 +311,7 @@ export function ReaderAttachments({ 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) => ( - + menu.openContextMenu(event, attachment) : undefined}> {renderItem ? renderItem(attachment, (drag) => draw(attachment, drag)) : draw(attachment, NO_DRAG)} @@ -324,6 +343,19 @@ export function ReaderAttachments({ )} + {desktopMenu && menuTarget && ( + + {!locked(menuTarget) && ( + { menu.closeContextMenu(); onOpen(menuTarget); }} /> + )} + {!downloadsOff && ( + { menu.closeContextMenu(); onDownload(menuTarget); }} /> + )} + {!downloadsOff && onCopyFile && ( + { menu.closeContextMenu(); onCopyFile(menuTarget); }} /> + )} + + )} ); } @@ -332,6 +364,25 @@ export function ReaderAttachments({ * 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 }) { + const t = useTranslations("desktopOs"); + return ( + { + void copy().then((ok) => { + if (ok) toast.success(t("copied")); + else toast.error(t("copy_failed")); + }, () => toast.error(t("copy_failed"))); + }} + > + + + ); +} + function AttachmentThumb({ attachment, url, label }: { attachment: ReaderAttachmentItem; url?: string; label: string }) { if (url) { return ; diff --git a/components/files/preview-shell.tsx b/components/files/preview-shell.tsx index 50ada2993..8c53bb15e 100644 --- a/components/files/preview-shell.tsx +++ b/components/files/preview-shell.tsx @@ -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. */} e.stopPropagation()} > diff --git a/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx b/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx new file mode 100644 index 000000000..ffb61683c --- /dev/null +++ b/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx @@ -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(); + 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); + }); +}); diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 505f868e6..9c424eabe 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -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({ ); })} + {waiting.map((wait) => ( + { + setOpen(false); + router.push(`/login?mode=add-account&username=${encodeURIComponent(wait.username)}` as never); + }} + > + + + {toUnicodeEmail(wait.label)} + {tWipe("needs_sign_in")} + + {wait.unsent + ? tWipe("switcher_waiting", { count: wait.unsent, date: waitDate(wait.until) }) + : tWipe("switcher_kept", { date: waitDate(wait.until) })} + + + + ))} {/* Mountain View: the account actions as rows closing the white card, diff --git a/components/layout/inline-app-view.tsx b/components/layout/inline-app-view.tsx index c74296236..970116fef 100644 --- a/components/layout/inline-app-view.tsx +++ b/components/layout/inline-app-view.tsx @@ -20,7 +20,7 @@ export function InlineAppView({ apps, activeAppId, onClose, className }: InlineA return ( {/* Header bar */} - + {activeApp?.name} diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 1ebaed59c..87e6d9fa8 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -596,6 +596,7 @@ export function NavigationRail({ })()} (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; +} diff --git a/components/layout/window-menu-bar.tsx b/components/layout/window-menu-bar.tsx new file mode 100644 index 000000000..371cead30 --- /dev/null +++ b/components/layout/window-menu-bar.tsx @@ -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(null); + const stripRef = useRef(null); + const previousFocus = useRef(null); + // What to do once the names are on screen: open that menu, or null to focus the first name. + const afterShow = useRef(undefined); + + const openMenu = useCallback((id: string) => { + const button = stripRef.current?.querySelector(`[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("button")?.focus(); + }, [entries, openMenu]); + + if (!entries) return null; + + const onStripKey = (event: React.KeyboardEvent) => { + const buttons = Array.from(stripRef.current?.querySelectorAll("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 ( + + {entries.map((entry) => ( + openMenu(entry.id)} + className="rounded px-2.5 py-1 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none" + > + {entry.label} + + ))} + + ); +} diff --git a/components/layout/window-title-bar.tsx b/components/layout/window-title-bar.tsx deleted file mode 100644 index 7f4e306fa..000000000 --- a/components/layout/window-title-bar.tsx +++ /dev/null @@ -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(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 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 ( - - {title} - - ); -} diff --git a/components/mail-transfer/__tests__/local-export.test.tsx b/components/mail-transfer/__tests__/local-export.test.tsx new file mode 100644 index 000000000..4d25a4cbe --- /dev/null +++ b/components/mail-transfer/__tests__/local-export.test.tsx @@ -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 => { + 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][]) => { + 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) { + 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; abort: ReturnType; close: ReturnType } { + 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[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(); + 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; + 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(); + 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(); + 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(); + 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(); + 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(); + 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) { + 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); + // 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(); + 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(); + 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(); + 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(); + 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; + 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(); + 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(); + 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(); + 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(); + 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(); + 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][]) => ( + 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(); + 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(); + 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(); + 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)).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(); + 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(); + 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(); + await finishedJob(memorySink('a.mbox', 'application/mbox') as ReturnType); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await finishedJob(); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('done'); + }); +}); diff --git a/components/mail-transfer/local-export-dialog.tsx b/components/mail-transfer/local-export-dialog.tsx new file mode 100644 index 000000000..c72d05329 --- /dev/null +++ b/components/mail-transfer/local-export-dialog.tsx @@ -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 { + 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 { + 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(null); + const [loadFailed, setLoadFailed] = useState(false); + const [excluded, setExcluded] = useState>(new Set()); + const [format, setFormat] = useState("mbox"); + const [starting, setStarting] = useState(false); + const [refused, setRefused] = useState(null); + const mode = saveMode(); + const progress = job?.kind === "export" ? job.progress : null; + const active = isJobActive(job); + const stageButton = useRef(null); + const allFolders = useRef(null); + const knownFolders = useRef>(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 ( + (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 ? ( + <> + {tExport("background")} + cancelJob()} aria-describedby={`${id}-status`} data-testid="local-export-cancel">{tExport("cancel_export")} + > + ) : ( + <> + {progress.phase === "done" && progress.file?.download && ( + progress.file?.download?.()} data-testid="mail-export-download">{tExport("download_again")} + )} + {notKeptCount > 0 && ( + useMailTransferStore.getState().open(request)} data-testid="local-export-retry">{t("retry")} + )} + close()}>{tExport("close")} + > + )} + > + + {progress.phase === "cancelled" && reason === "policy" && {tPolicy("export_blocked")}} + {progress.phase === "cancelled" && reason === "offline-off" && {t("stopped_offline_off")}} + {progress.phase === "done" && progress.exported === 0 && progress.failed === 0 && {t("nothing_kept")}} + {leftover && {t("leftover_file", { name: leftover })}} + {notKeptCount > 0 && ( + + {!!notKept?.attachments_off && {t("not_kept_attachments_off", { count: notKept.attachments_off })}} + {!!notKept?.not_kept && {t("not_kept_not_kept", { count: notKept.not_kept })}} + {reachable ? t("retry_online") : t("retry_offline")} + + )} + + ); + } + + 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 ( + close()} + size="full-on-phone" + title={t("title")} + description={t("description")} + testId="local-export-dialog" + footer={( + <> + close()}>{tExport("cancel")} + void onStart()} disabled={starting || !loaded || nothingToWrite || overLimit || noRoom} data-testid="local-export-start">{tExport("start")} + > + )} + > + + {loadFailed ? ( + {t("load_failed")} + ) : !loaded ? ( + {t("loading")} + ) : ( + <> + + {t("folders_label")} + + { + 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")} + + + {rows.map((row) => ( + + + toggleFolder(row.folder.mailboxId, event.target.checked)} + data-testid="local-export-folder" + data-folder={row.folder.path.join("/")} + /> + {row.folder.path.join(" / ")} + {t("folder_kept", { kept: row.kept, total: row.total })} + + + ))} + + + + {tExport("format_label")} + + {formats.map((f) => ( + + setFormat(f.value)} + className="mt-0.5" + aria-describedby={`${id}-${f.value}-hint`} + data-testid={`local-export-format-${f.value}`} + /> + + {f.label} + {f.hint} + + + ))} + + + + {total === 0 ? ( + {t("nothing_kept")} + ) : ( + {t("kept", { kept, total })} + )} + {missing > 0 && ( + + {reachable ? t("missing_online", { count: missing }) : t("missing_offline", { count: missing })} + + )} + {missing > 0 && !reachable && ( + {attachmentsOff ? t("reason_attachments_off") : t("reason_not_kept")} + )} + {overLimit && {t("over_limit", { limit: formatFileSize(limit), size: formatFileSize(needed) })}} + {noRoom && !overLimit && {t("no_room", { size: formatFileSize(needed), free: formatFileSize(loaded?.free ?? 0) })}} + {refused && {refused}} + + {tExport(`save_${mode}`)} + > + )} + + + ); +} diff --git a/components/mail-transfer/mail-export-dialog.tsx b/components/mail-transfer/mail-export-dialog.tsx index c3e7b6b99..9c09d14b7 100644 --- a/components/mail-transfer/mail-export-dialog.tsx +++ b/components/mail-transfer/mail-export-dialog.tsx @@ -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; -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. diff --git a/components/mail-transfer/mail-transfer-host.tsx b/components/mail-transfer/mail-transfer-host.tsx index c48444ea9..bacd2ff95 100644 --- a/components/mail-transfer/mail-transfer-host.tsx +++ b/components/mail-transfer/mail-transfer-host.tsx @@ -4,19 +4,48 @@ 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"; import { forgetLocalImportFor, isDeviceOnlyClient, resumeLocalImport, setImportTaskbarProgress } from "./local-import-job"; /** 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 { + // 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 @@ -32,19 +61,17 @@ 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); await forgetLocalImportFor(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); @@ -52,6 +79,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"); @@ -96,8 +137,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 }); } @@ -109,6 +150,7 @@ export function MailTransferHost() { <> {request?.kind === "import" && } {request?.kind === "export" && } + {request?.kind === "local-export" && } {!visible && isJobActive(job) && } > ); diff --git a/components/mail-transfer/start-local-export.ts b/components/mail-transfer/start-local-export.ts new file mode 100644 index 000000000..ca40ea492 --- /dev/null +++ b/components/mail-transfer/start-local-export.ts @@ -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 { + const session = await getStore().area(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((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; +} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index a87fb51aa..c6d045964 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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) => { diff --git a/components/offline/__tests__/device-wipe-host.test.tsx b/components/offline/__tests__/device-wipe-host.test.tsx new file mode 100644 index 000000000..f2cf3373e --- /dev/null +++ b/components/offline/__tests__/device-wipe-host.test.tsx @@ -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(); + 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(); + 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(); + 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(); + expect(warning).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/offline/__tests__/offline-settings.test.tsx b/components/offline/__tests__/offline-settings.test.tsx index 3113b413e..66e545e40 100644 --- a/components/offline/__tests__/offline-settings.test.tsx +++ b/components/offline/__tests__/offline-settings.test.tsx @@ -46,6 +46,29 @@ describe('Settings > Offline', () => { } }); + it('offers removing everything where offline mode exists', () => { + render(); + 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(); + expect(screen.queryByTestId('wipe-device-section')).toBeNull(); + privateWindow.unmount(); + useOfflineStore.setState({ storageBlocked: false }); + + setStoreBackend({ supported: () => false } as unknown as StoreBackend); + const noStore = render(); + expect(screen.queryByTestId('wipe-device-section')).toBeNull(); + noStore.unmount(); + setStoreBackend({ supported: () => true } as unknown as StoreBackend); + + setNoOffline(true); + render(); + expect(screen.queryByTestId('wipe-device-section')).toBeNull(); + }); + it('is off until the user turns it on in this browser', () => { render(); const toggle = screen.getByTestId('offline-switch'); diff --git a/components/offline/__tests__/wipe-device-section.test.tsx b/components/offline/__tests__/wipe-device-section.test.tsx new file mode 100644 index 000000000..d07d03149 --- /dev/null +++ b/components/offline/__tests__/wipe-device-section.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx index d58d07a1d..80666f60c 100644 --- a/components/offline/account-storage.tsx +++ b/components/offline/account-storage.tsx @@ -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; )} + + openLocalExport(account)} data-testid={testId('local-export-open')}>{tExport('settings_button')} + {tStorage('clear_button')} diff --git a/components/offline/device-wipe-host.tsx b/components/offline/device-wipe-host.tsx new file mode 100644 index 000000000..405e94a82 --- /dev/null +++ b/components/offline/device-wipe-host.tsx @@ -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; +} diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx index 5271e670f..1bfd3706a 100644 --- a/components/offline/offline-settings.tsx +++ b/components/offline/offline-settings.tsx @@ -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)) && } > )} diff --git a/components/offline/wipe-device-section.tsx b/components/offline/wipe-device-section.tsx new file mode 100644 index 000000000..d620843cf --- /dev/null +++ b/components/offline/wipe-device-section.tsx @@ -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(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 ( + + + {t('settings_title')} + {t('settings_description')} + + { void ask(); }} + data-testid="wipe-device-button" + > + {wiping ? t('settings_wiping') : t('settings_button')} + + setAsking(false)} + onConfirm={confirmed} + title={t('confirm_title')} + message={t('confirm_message')} + details={( + + {t('confirm_mail')} + {unsent === null && {t('confirm_unsent_unknown')}} + {unsent !== null && unsent > 0 && {t('confirm_unsent', { count: unsent })}} + {t('confirm_keys')} + {t('confirm_plugins')} + {t('confirm_files')} + {t('confirm_templates')} + {t('confirm_accounts')} + + )} + confirmText={t('confirm_button')} + variant="destructive" + /> + + ); +} diff --git a/components/plugins/app-side-panel.tsx b/components/plugins/app-side-panel.tsx index 2db5e2829..35ce18c5b 100644 --- a/components/plugins/app-side-panel.tsx +++ b/components/plugins/app-side-panel.tsx @@ -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', diff --git a/components/plugins/plugin-side-panels.tsx b/components/plugins/plugin-side-panels.tsx index b81d389e2..d6d4bee15 100644 --- a/components/plugins/plugin-side-panels.tsx +++ b/components/plugins/plugin-side-panels.tsx @@ -142,6 +142,7 @@ export function PluginSidePanel() { {active && (
+ {desktopPolicyNotice} +
- {desktopPolicyNotice} -
{desktopNotice} diff --git a/app/(main)/[locale]/popout/spare/page.tsx b/app/(main)/[locale]/popout/spare/page.tsx new file mode 100644 index 000000000..1eafe6639 --- /dev/null +++ b/app/(main)/[locale]/popout/spare/page.tsx @@ -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 ; +} diff --git a/app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx b/app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx new file mode 100644 index 000000000..535f76f13 --- /dev/null +++ b/app/(main)/admin/_pages/__tests__/device-registry-panel.test.tsx @@ -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 => ({ + 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>; + +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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(await screen.findByTestId('devices-empty')).toBeTruthy(); + }); +}); diff --git a/app/(main)/admin/_pages/device-registry-panel.tsx b/app/(main)/admin/_pages/device-registry-panel.tsx new file mode 100644 index 000000000..eba321350 --- /dev/null +++ b/app/(main)/admin/_pages/device-registry-panel.tsx @@ -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(null); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [query, setQuery] = useState(''); + const [search, setSearch] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [pending, setPending] = useState(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) => { + 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(); + 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 ( + + { event.preventDefault(); setOffset(0); setSearch(query.trim()); }} + > + 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" + /> + Search + + {error && {error}} + {devices !== null && devices.length === 0 && ( + + {search ? 'No device matches that search.' : 'No device has registered yet.'} + + )} + + {groups.map((group) => { + const wipeable = group.rows.filter((device) => !device.wipe && !device.wipedAt); + return ( + + + + {group.user} + {group.server} + + {wipeable.length > 1 && ( + setPending({ kind: 'user', userKey: group.userKey, user: group.user, count: wipeable.length })} + data-testid="devices-wipe-user" + > + Wipe all {wipeable.length} devices + + )} + + + {group.rows.map((device) => ( + Wipe waiting + : device.wipedAt + ? Wiped + : 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={( + + {!device.wipe && !device.wipedAt && ( + setPending({ kind: 'device', device })} + data-testid="device-wipe" + > + Wipe + + )} + {device.wipe && ( + { void act(device.id, { action: 'cancel', id: device.id }); }} + data-testid="device-cancel" + > + Take back + + )} + setPending({ kind: 'forget', device })} + data-testid="device-forget" + > + Forget + + + )} + /> + ))} + + + ); + })} + + {total > PAGE_SIZE && ( + + {offset + 1} to {Math.min(offset + PAGE_SIZE, total)} of {total} + + setOffset(Math.max(0, offset - PAGE_SIZE))}>Previous + = total} onClick={() => setOffset(offset + PAGE_SIZE)}>Next + + + )} + 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" + /> + + ); +} + +function wipeReasonLabel(reason: NonNullable): 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.`; +} diff --git a/app/(main)/admin/_pages/logs.tsx b/app/(main)/admin/_pages/logs.tsx index 4077e9732..d6930eeb2 100644 --- a/app/(main)/admin/_pages/logs.tsx +++ b/app/(main)/admin/_pages/logs.tsx @@ -63,6 +63,10 @@ export function LogsPage() { Config Update Config Revert Policy Update + Device Wipe Requested + Device Wipe Confirmed + Device Wipe Cancelled + Device Forgotten diff --git a/app/(main)/admin/_pages/org-devices.tsx b/app/(main)/admin/_pages/org-devices.tsx index a7cf2834d..c542cffe8 100644 --- a/app/(main)/admin/_pages/org-devices.tsx +++ b/app/(main)/admin/_pages/org-devices.tsx @@ -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() { + ); } diff --git a/app/(main)/admin/layout.tsx b/app/(main)/admin/layout.tsx index c666fd358..4ba067930 100644 --- a/app/(main)/admin/layout.tsx +++ b/app/(main)/admin/layout.tsx @@ -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 + {children} diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index e2704bcc8..e259ae6ad 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -20,7 +20,7 @@ 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"; @@ -334,7 +334,7 @@ export default async function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} ${hankenGrotesk.variable} antialiased`} > - + {!themeColorConfigured && } diff --git a/app/api/admin/devices/__tests__/auth.test.ts b/app/api/admin/devices/__tests__/auth.test.ts new file mode 100644 index 000000000..77cb78375 --- /dev/null +++ b/app/api/admin/devices/__tests__/auth.test.ts @@ -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()); +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): 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(); + }); +}); diff --git a/app/api/admin/devices/route.ts b/app/api/admin/devices/route.ts new file mode 100644 index 000000000..8e0ac47d4 --- /dev/null +++ b/app/api/admin/devices/route.ts @@ -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 { + 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 { + 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 }); + } +} diff --git a/app/api/devices/__tests__/route.test.ts b/app/api/devices/__tests__/route.test.ts new file mode 100644 index 000000000..055485d39 --- /dev/null +++ b/app/api/devices/__tests__/route.test.ts @@ -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(), + 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 = {}): 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; 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}"`); + } + }); +}); diff --git a/app/api/devices/route.ts b/app/api/devices/route.ts new file mode 100644 index 000000000..3d3be4c91 --- /dev/null +++ b/app/api/devices/route.ts @@ -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; + try { + body = JSON.parse(text) as Record; + } 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 }); + } +} diff --git a/app/api/devices/wiped/route.ts b/app/api/devices/wiped/route.ts new file mode 100644 index 000000000..31c10e2b3 --- /dev/null +++ b/app/api/devices/wiped/route.ts @@ -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; + try { + body = JSON.parse(text) as Record; + } 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 }); + } +} diff --git a/app/api/errors/__tests__/route.test.ts b/app/api/errors/__tests__/route.test.ts index d53e1dde6..c32f23499 100644 --- a/app/api/errors/__tests__/route.test.ts +++ b/app/api/errors/__tests__/route.test.ts @@ -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); + }); +}); diff --git a/app/api/errors/route.ts b/app/api/errors/route.ts index b31a38952..0bdb02235 100644 --- a/app/api/errors/route.ts +++ b/app/api/errors/route.ts @@ -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 }); diff --git a/app/api/plugin-secrets/__tests__/wipe.test.ts b/app/api/plugin-secrets/__tests__/wipe.test.ts new file mode 100644 index 000000000..075138ac9 --- /dev/null +++ b/app/api/plugin-secrets/__tests__/wipe.test.ts @@ -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 = { 'sec-fetch-site': 'same-origin' }): Promise { + 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' }]); + }); +}); diff --git a/app/api/plugin-secrets/route.ts b/app/api/plugin-secrets/route.ts index e235b5a0c..b767e0dbe 100644 --- a/app/api/plugin-secrets/route.ts +++ b/app/api/plugin-secrets/route.ts @@ -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'); diff --git a/app/desktop.css b/app/desktop.css index 7f1b55c74..49ca879bc 100644 --- a/app/desktop.css +++ b/app/desktop.css @@ -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; diff --git a/app/globals.css b/app/globals.css index 212b080c2..30a67a4e3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); diff --git a/app/manifest.ts b/app/manifest.ts index c95478c26..41f989ba2 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -126,7 +126,7 @@ export default async function manifest(): Promise { 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, diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index 857ab630b..0c2ffeaf7 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -442,6 +442,10 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, + {/* Only for the window's controls: the sidebar says which page is open. */} + + {pageName ?? 'Admin'} + {readOnlyBanner} diff --git a/components/admin/org/__tests__/offline-section.test.tsx b/components/admin/org/__tests__/offline-section.test.tsx index a5051d4fb..2b5630630 100644 --- a/components/admin/org/__tests__/offline-section.test.tsx +++ b/components/admin/org/__tests__/offline-section.test.tsx @@ -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')); diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index 359648627..9a806ccbc 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -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 ( draft.clearOrgValue('devices', 'noOfflineAttachments')} /> + + + draft.setOrgValue('devices', 'wipeOnForcedSignOut', v)} testId="org-devices-wipeOnForcedSignOut" /> + draft.clearOrgValue('devices', 'wipeOnForcedSignOut')} /> + + 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.{' '} How offline mode works diff --git a/components/boot-skeleton/admin-skeleton.tsx b/components/boot-skeleton/admin-skeleton.tsx index 16bc29d97..0db2ff49b 100644 --- a/components/boot-skeleton/admin-skeleton.tsx +++ b/components/boot-skeleton/admin-skeleton.tsx @@ -227,6 +227,9 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla + + + diff --git a/components/boot-skeleton/calendar-skeleton.tsx b/components/boot-skeleton/calendar-skeleton.tsx index 28191a02c..87011e717 100644 --- a/components/boot-skeleton/calendar-skeleton.tsx +++ b/components/boot-skeleton/calendar-skeleton.tsx @@ -176,7 +176,7 @@ function ToolbarSkeleton({ text }: { text?: CalendarSkeletonText }) { const views = text?.views; return ( <> - + diff --git a/components/boot-skeleton/files-skeleton.tsx b/components/boot-skeleton/files-skeleton.tsx index 840ec325e..6802ccd98 100644 --- a/components/boot-skeleton/files-skeleton.tsx +++ b/components/boot-skeleton/files-skeleton.tsx @@ -199,7 +199,7 @@ function FilesSidebarSkeleton() { function FilesHeaderSkeleton({ picker }: { picker: boolean }) { const on = "bg-primary/10 text-primary"; return ( - + {picker ? null : } diff --git a/components/boot-skeleton/login-skeleton.tsx b/components/boot-skeleton/login-skeleton.tsx index c36e4ef8d..28141c548 100644 --- a/components/boot-skeleton/login-skeleton.tsx +++ b/components/boot-skeleton/login-skeleton.tsx @@ -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. */} - + diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx index 063d040ba..a13165166 100644 --- a/components/boot-skeleton/mail-skeleton.tsx +++ b/components/boot-skeleton/mail-skeleton.tsx @@ -411,7 +411,7 @@ export function MailMessageToolbarSkeleton({ end }: { end?: ReactNode } = {}) { const button = "h-8 w-20 rounded-md"; return ( - + {/* Phones: back, then icon-over-label actions. */} diff --git a/components/boot-skeleton/pro-skeleton.tsx b/components/boot-skeleton/pro-skeleton.tsx index 3e6eecda0..6065e72d5 100644 --- a/components/boot-skeleton/pro-skeleton.tsx +++ b/components/boot-skeleton/pro-skeleton.tsx @@ -34,6 +34,8 @@ function TabStripSkeleton() { return ( diff --git a/components/boot-skeleton/settings-skeleton.tsx b/components/boot-skeleton/settings-skeleton.tsx index 7d8c868ec..f9d73e546 100644 --- a/components/boot-skeleton/settings-skeleton.tsx +++ b/components/boot-skeleton/settings-skeleton.tsx @@ -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 ( - + @@ -270,6 +270,11 @@ export function SettingsSkeletonBody({ embedded = false }: { embedded?: boolean + {embedded ? null : ( + + + + )} diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 75fb07763..40c372390 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -225,7 +225,7 @@ export function CalendarToolbar({ {/* ── MOBILE TOOLBAR ── */} {isMobile && ( - + {/* Row 1 while searching: the search box with a way back. */} {mobileSearchField ? ( diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 57fad15a2..94438b553 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -1037,7 +1037,7 @@ export function EventModal({ {/* Header */} - + diff --git a/components/calendar/missed-reminders-dialog.tsx b/components/calendar/missed-reminders-dialog.tsx new file mode 100644 index 000000000..2b4ed8af3 --- /dev/null +++ b/components/calendar/missed-reminders-dialog.tsx @@ -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 ( + + { + finish(); + window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`; + }} + > + {t("open_calendar")} + + {t("done")} + > + )} + > + + {alerts.map((alert) => ( + + + {alert.title || tAlert("alert_title")} + + {format.format(new Date(alert.startMs ?? alert.fireTimeMs))} + {alert.calendarName ? ` · ${alert.calendarName}` : ""} + + + {alert.joinUrl && ( + void joinMeeting(alert.joinUrl!)} + > + {joinLabel(alert, joinLabels)} + + )} + + ))} + + + ); +} diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index a23cfd610..75ea6439a 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -222,7 +222,7 @@ export function TaskModal({ onKeyDown={handleKeyDown} > {/* Header */} - + {isEdit ? t("tasks.edit") : t("tasks.create")} diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 2b0b382d3..2f6b44f0d 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -99,7 +99,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli if (!contact) { return ( - + + {/* The window's controls lie over this end, and its empty top is something to drag by. */} + {t("detail.no_contact_selected")} @@ -139,7 +141,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli return ( - + diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index d35baf1af..dbd29960a 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -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. - + {isEditing ? t("edit_title") : t("create_title")} diff --git a/components/contacts/contact-group-detail.tsx b/components/contacts/contact-group-detail.tsx index 68d99cf85..48a251453 100644 --- a/components/contacts/contact-group-detail.tsx +++ b/components/contacts/contact-group-detail.tsx @@ -48,7 +48,7 @@ export function ContactGroupDetail({ return ( - + diff --git a/components/contacts/contact-group-form.tsx b/components/contacts/contact-group-form.tsx index c1c11278f..58e5b8bce 100644 --- a/components/contacts/contact-group-form.tsx +++ b/components/contacts/contact-group-form.tsx @@ -88,7 +88,7 @@ export function ContactGroupForm({ return ( - + {isEditing ? t("groups.edit") : t("groups.create")} diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx new file mode 100644 index 000000000..9f6da218b --- /dev/null +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -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)[] })); +vi.mock('@/lib/sign-out-cleanup', async (original) => ({ + ...(await original()), + onFullSignOut: (task: () => void) => signOut.full.push(task), + onBeforeSignOut: (task: () => Promise) => signOut.before.push(task), +})); + +const { DesktopHost } = await import('../desktop-host'); + +function fakeBridge() { + let command: ((id: string) => void) | null = null; + let flush: (() => Promise) | 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) => { 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()).not.toThrow(); + }); + + it('runs a command from what\'s on the page first', () => { + const shell = fakeBridge(); + render(); + 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(); + 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(); + shell.run('link.folder:sent'); + expect(push).toHaveBeenLastCalledWith('/mail/folder/sent'); + }); + + it('an unknown command does nothing', () => { + const shell = fakeBridge(); + render(); + 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(); + 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(); + 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(); + await act(async () => {}); + expect(shell.sessionReady).not.toHaveBeenCalled(); + first.unmount(); + useAuthStore.setState({ isAuthenticated: true } as never); + window.history.replaceState(null, '', '/popout/compose'); + render(); + await act(async () => {}); + expect(shell.sessionReady).not.toHaveBeenCalled(); + }); + + it('passes the restore setting on', async () => { + const shell = fakeBridge(); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await act(async () => {}); + expect(first.allowDownload).not.toHaveBeenCalled(); + view.unmount(); + setDesktopPref('dictionaryDownload', 'ask'); + const shell = fakeBridge(); + render(); + 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(); + 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(); + 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(); + act(() => useOfflineStore.setState({ outbox: [{ id: 1 }, { id: 2 }] } as never)); + await expect(shell.flush()).resolves.toEqual({ unsent: 2 }); + act(() => useOfflineStore.setState({ outbox: [] } as never)); + }); +}); diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx new file mode 100644 index 000000000..f8295a3ad --- /dev/null +++ b/components/desktop/desktop-host.tsx @@ -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 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(); + 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; +} diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx new file mode 100644 index 000000000..a6e522136 --- /dev/null +++ b/components/desktop/desktop-os-host.tsx @@ -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(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(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; +} diff --git a/components/device/__tests__/device-gates.test.tsx b/components/device/__tests__/device-gates.test.tsx index bb86a6b7b..5557f2eed 100644 --- a/components/device/__tests__/device-gates.test.tsx +++ b/components/device/__tests__/device-gates.test.tsx @@ -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'); }); }); diff --git a/components/device/device-policy-host.tsx b/components/device/device-policy-host.tsx index d7465be08..acfaa9c7c 100644 --- a/components/device/device-policy-host.tsx +++ b/components/device/device-policy-host.tsx @@ -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 }); diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx index 8ac5e5b82..9ef9370a7 100644 --- a/components/email/__tests__/composer-close-guard.test.tsx +++ b/components/email/__tests__/composer-close-guard.test.tsx @@ -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(); diff --git a/components/email/__tests__/reader-attachments.test.tsx b/components/email/__tests__/reader-attachments.test.tsx index 8e0f1fae4..67c6913e7 100644 --- a/components/email/__tests__/reader-attachments.test.tsx +++ b/components/email/__tests__/reader-attachments.test.tsx @@ -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 { + afterEach(() => { + window.bulwarkDesktop = undefined; + cleanup(); + }); + + const openMenu = async (popupAnswer: (items: { id?: string; label?: string }[]) => string | null, overrides: Partial[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>)[handler]).toHaveBeenCalledTimes(1)); + expect((props as unknown as Record>)[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']); diff --git a/components/email/__tests__/viewer-mark-read.test.tsx b/components/email/__tests__/viewer-mark-read.test.tsx index 86e0f4dd8..569b0c285 100644 --- a/components/email/__tests__/viewer-mark-read.test.tsx +++ b/components/email/__tests__/viewer-mark-read.test.tsx @@ -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(); + 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(); diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx index 759465667..fd95d5e73 100644 --- a/components/email/__tests__/viewer-send-failures.test.tsx +++ b/components/email/__tests__/viewer-send-failures.test.tsx @@ -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(); + 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(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 44a474b99..d584a8c44 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -169,8 +169,12 @@ 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 { 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 +387,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,6 +1947,14 @@ 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; @@ -2698,6 +2710,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(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(null); const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId); @@ -3017,6 +3035,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 +3117,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; @@ -3360,6 +3380,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 +4458,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 +4492,7 @@ export function EmailComposer({ return; } afterCloseRef.current = null; - setCloseDialogKind('discard'); - setShowCloseDialog(true); + openCloseDialog('discard'); }; const handleClose = (afterClose?: () => void) => { @@ -4457,8 +4504,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 +4790,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} > diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 4f431dd60..092ca7f82 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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) => void; onDragEnd: (e: React.DragEvent) => void; + onCopyAsFile?: () => Promise; }) => React.ReactNode; } @@ -685,6 +692,19 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl const source = useMemo(() => ({ 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(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( - + + {/* An empty reading pane's top is still the window's top, to drag by. */} + diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx index 0e3aa1fbd..167767905 100644 --- a/components/email/reader-attachments.tsx +++ b/components/email/reader-attachments.tsx @@ -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) => void; onDragEnd: (e: DragEvent) => void; + /** The desktop app only. */ + onCopyAsFile?: () => Promise; } /** Past this many tiles the bar shows one row less and a "Show N more" tile. */ @@ -47,6 +53,11 @@ interface ReaderAttachmentsProps { layout?: ReaderAttachmentLayout; /** Object URLs of image attachments, keyed by attachment id. */ thumbUrls: Record; + /** + * 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({ onDownload, onDownloadAll, onSaveAllToFolder, + onCopyFile, downloadingAll, downloadsOff = false, onSaveToFiles, @@ -105,6 +117,11 @@ export function ReaderAttachments({ const tFiles = useTranslations("files"); const tDevice = useTranslations("device_policy"); const tWindows = useTranslations("pwaWindows"); + const tDesktop = useTranslations("desktopUi.attachment"); + const menu = useContextMenu(); + 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({ )} {pluginMenu?.(attachment)} + {!downloadsOff && drag.onCopyAsFile && } {!downloadsOff && ( ({ )} {pluginMenu?.(attachment)} + {!downloadsOff && drag.onCopyAsFile && } {!downloadsOff && ( onDownload(attachment)}> @@ -292,7 +311,7 @@ export function ReaderAttachments({ 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) => ( - + menu.openContextMenu(event, attachment) : undefined}> {renderItem ? renderItem(attachment, (drag) => draw(attachment, drag)) : draw(attachment, NO_DRAG)} @@ -324,6 +343,19 @@ export function ReaderAttachments({ )} + {desktopMenu && menuTarget && ( + + {!locked(menuTarget) && ( + { menu.closeContextMenu(); onOpen(menuTarget); }} /> + )} + {!downloadsOff && ( + { menu.closeContextMenu(); onDownload(menuTarget); }} /> + )} + {!downloadsOff && onCopyFile && ( + { menu.closeContextMenu(); onCopyFile(menuTarget); }} /> + )} + + )} ); } @@ -332,6 +364,25 @@ export function ReaderAttachments({ * 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 }) { + const t = useTranslations("desktopOs"); + return ( + { + void copy().then((ok) => { + if (ok) toast.success(t("copied")); + else toast.error(t("copy_failed")); + }, () => toast.error(t("copy_failed"))); + }} + > + + + ); +} + function AttachmentThumb({ attachment, url, label }: { attachment: ReaderAttachmentItem; url?: string; label: string }) { if (url) { return ; diff --git a/components/files/preview-shell.tsx b/components/files/preview-shell.tsx index 50ada2993..8c53bb15e 100644 --- a/components/files/preview-shell.tsx +++ b/components/files/preview-shell.tsx @@ -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. */} e.stopPropagation()} > diff --git a/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx b/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx new file mode 100644 index 000000000..ffb61683c --- /dev/null +++ b/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx @@ -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(); + 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); + }); +}); diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 505f868e6..9c424eabe 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -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({ ); })} + {waiting.map((wait) => ( + { + setOpen(false); + router.push(`/login?mode=add-account&username=${encodeURIComponent(wait.username)}` as never); + }} + > + + + {toUnicodeEmail(wait.label)} + {tWipe("needs_sign_in")} + + {wait.unsent + ? tWipe("switcher_waiting", { count: wait.unsent, date: waitDate(wait.until) }) + : tWipe("switcher_kept", { date: waitDate(wait.until) })} + + + + ))} {/* Mountain View: the account actions as rows closing the white card, diff --git a/components/layout/inline-app-view.tsx b/components/layout/inline-app-view.tsx index c74296236..970116fef 100644 --- a/components/layout/inline-app-view.tsx +++ b/components/layout/inline-app-view.tsx @@ -20,7 +20,7 @@ export function InlineAppView({ apps, activeAppId, onClose, className }: InlineA return ( {/* Header bar */} - + {activeApp?.name} diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 1ebaed59c..87e6d9fa8 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -596,6 +596,7 @@ export function NavigationRail({ })()} (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; +} diff --git a/components/layout/window-menu-bar.tsx b/components/layout/window-menu-bar.tsx new file mode 100644 index 000000000..371cead30 --- /dev/null +++ b/components/layout/window-menu-bar.tsx @@ -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(null); + const stripRef = useRef(null); + const previousFocus = useRef(null); + // What to do once the names are on screen: open that menu, or null to focus the first name. + const afterShow = useRef(undefined); + + const openMenu = useCallback((id: string) => { + const button = stripRef.current?.querySelector(`[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("button")?.focus(); + }, [entries, openMenu]); + + if (!entries) return null; + + const onStripKey = (event: React.KeyboardEvent) => { + const buttons = Array.from(stripRef.current?.querySelectorAll("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 ( + + {entries.map((entry) => ( + openMenu(entry.id)} + className="rounded px-2.5 py-1 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none" + > + {entry.label} + + ))} + + ); +} diff --git a/components/layout/window-title-bar.tsx b/components/layout/window-title-bar.tsx deleted file mode 100644 index 7f4e306fa..000000000 --- a/components/layout/window-title-bar.tsx +++ /dev/null @@ -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(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 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 ( - - {title} - - ); -} diff --git a/components/mail-transfer/__tests__/local-export.test.tsx b/components/mail-transfer/__tests__/local-export.test.tsx new file mode 100644 index 000000000..4d25a4cbe --- /dev/null +++ b/components/mail-transfer/__tests__/local-export.test.tsx @@ -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 => { + 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][]) => { + 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) { + 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; abort: ReturnType; close: ReturnType } { + 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[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(); + 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; + 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(); + 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(); + 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(); + 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(); + 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(); + 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) { + 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); + // 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(); + 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(); + 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(); + 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(); + 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; + 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(); + 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(); + 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(); + 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(); + 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(); + 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][]) => ( + 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(); + 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(); + 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(); + 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)).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(); + 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(); + 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(); + await finishedJob(memorySink('a.mbox', 'application/mbox') as ReturnType); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await finishedJob(); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('done'); + }); +}); diff --git a/components/mail-transfer/local-export-dialog.tsx b/components/mail-transfer/local-export-dialog.tsx new file mode 100644 index 000000000..c72d05329 --- /dev/null +++ b/components/mail-transfer/local-export-dialog.tsx @@ -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 { + 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 { + 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(null); + const [loadFailed, setLoadFailed] = useState(false); + const [excluded, setExcluded] = useState>(new Set()); + const [format, setFormat] = useState("mbox"); + const [starting, setStarting] = useState(false); + const [refused, setRefused] = useState(null); + const mode = saveMode(); + const progress = job?.kind === "export" ? job.progress : null; + const active = isJobActive(job); + const stageButton = useRef(null); + const allFolders = useRef(null); + const knownFolders = useRef>(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 ( + (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 ? ( + <> + {tExport("background")} + cancelJob()} aria-describedby={`${id}-status`} data-testid="local-export-cancel">{tExport("cancel_export")} + > + ) : ( + <> + {progress.phase === "done" && progress.file?.download && ( + progress.file?.download?.()} data-testid="mail-export-download">{tExport("download_again")} + )} + {notKeptCount > 0 && ( + useMailTransferStore.getState().open(request)} data-testid="local-export-retry">{t("retry")} + )} + close()}>{tExport("close")} + > + )} + > + + {progress.phase === "cancelled" && reason === "policy" && {tPolicy("export_blocked")}} + {progress.phase === "cancelled" && reason === "offline-off" && {t("stopped_offline_off")}} + {progress.phase === "done" && progress.exported === 0 && progress.failed === 0 && {t("nothing_kept")}} + {leftover && {t("leftover_file", { name: leftover })}} + {notKeptCount > 0 && ( + + {!!notKept?.attachments_off && {t("not_kept_attachments_off", { count: notKept.attachments_off })}} + {!!notKept?.not_kept && {t("not_kept_not_kept", { count: notKept.not_kept })}} + {reachable ? t("retry_online") : t("retry_offline")} + + )} + + ); + } + + 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 ( + close()} + size="full-on-phone" + title={t("title")} + description={t("description")} + testId="local-export-dialog" + footer={( + <> + close()}>{tExport("cancel")} + void onStart()} disabled={starting || !loaded || nothingToWrite || overLimit || noRoom} data-testid="local-export-start">{tExport("start")} + > + )} + > + + {loadFailed ? ( + {t("load_failed")} + ) : !loaded ? ( + {t("loading")} + ) : ( + <> + + {t("folders_label")} + + { + 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")} + + + {rows.map((row) => ( + + + toggleFolder(row.folder.mailboxId, event.target.checked)} + data-testid="local-export-folder" + data-folder={row.folder.path.join("/")} + /> + {row.folder.path.join(" / ")} + {t("folder_kept", { kept: row.kept, total: row.total })} + + + ))} + + + + {tExport("format_label")} + + {formats.map((f) => ( + + setFormat(f.value)} + className="mt-0.5" + aria-describedby={`${id}-${f.value}-hint`} + data-testid={`local-export-format-${f.value}`} + /> + + {f.label} + {f.hint} + + + ))} + + + + {total === 0 ? ( + {t("nothing_kept")} + ) : ( + {t("kept", { kept, total })} + )} + {missing > 0 && ( + + {reachable ? t("missing_online", { count: missing }) : t("missing_offline", { count: missing })} + + )} + {missing > 0 && !reachable && ( + {attachmentsOff ? t("reason_attachments_off") : t("reason_not_kept")} + )} + {overLimit && {t("over_limit", { limit: formatFileSize(limit), size: formatFileSize(needed) })}} + {noRoom && !overLimit && {t("no_room", { size: formatFileSize(needed), free: formatFileSize(loaded?.free ?? 0) })}} + {refused && {refused}} + + {tExport(`save_${mode}`)} + > + )} + + + ); +} diff --git a/components/mail-transfer/mail-export-dialog.tsx b/components/mail-transfer/mail-export-dialog.tsx index c3e7b6b99..9c09d14b7 100644 --- a/components/mail-transfer/mail-export-dialog.tsx +++ b/components/mail-transfer/mail-export-dialog.tsx @@ -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; -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. diff --git a/components/mail-transfer/mail-transfer-host.tsx b/components/mail-transfer/mail-transfer-host.tsx index c48444ea9..bacd2ff95 100644 --- a/components/mail-transfer/mail-transfer-host.tsx +++ b/components/mail-transfer/mail-transfer-host.tsx @@ -4,19 +4,48 @@ 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"; import { forgetLocalImportFor, isDeviceOnlyClient, resumeLocalImport, setImportTaskbarProgress } from "./local-import-job"; /** 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 { + // 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 @@ -32,19 +61,17 @@ 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); await forgetLocalImportFor(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); @@ -52,6 +79,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"); @@ -96,8 +137,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 }); } @@ -109,6 +150,7 @@ export function MailTransferHost() { <> {request?.kind === "import" && } {request?.kind === "export" && } + {request?.kind === "local-export" && } {!visible && isJobActive(job) && } > ); diff --git a/components/mail-transfer/start-local-export.ts b/components/mail-transfer/start-local-export.ts new file mode 100644 index 000000000..ca40ea492 --- /dev/null +++ b/components/mail-transfer/start-local-export.ts @@ -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 { + const session = await getStore().area(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((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; +} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index a87fb51aa..c6d045964 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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) => { diff --git a/components/offline/__tests__/device-wipe-host.test.tsx b/components/offline/__tests__/device-wipe-host.test.tsx new file mode 100644 index 000000000..f2cf3373e --- /dev/null +++ b/components/offline/__tests__/device-wipe-host.test.tsx @@ -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(); + 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(); + 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(); + 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(); + expect(warning).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/offline/__tests__/offline-settings.test.tsx b/components/offline/__tests__/offline-settings.test.tsx index 3113b413e..66e545e40 100644 --- a/components/offline/__tests__/offline-settings.test.tsx +++ b/components/offline/__tests__/offline-settings.test.tsx @@ -46,6 +46,29 @@ describe('Settings > Offline', () => { } }); + it('offers removing everything where offline mode exists', () => { + render(); + 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(); + expect(screen.queryByTestId('wipe-device-section')).toBeNull(); + privateWindow.unmount(); + useOfflineStore.setState({ storageBlocked: false }); + + setStoreBackend({ supported: () => false } as unknown as StoreBackend); + const noStore = render(); + expect(screen.queryByTestId('wipe-device-section')).toBeNull(); + noStore.unmount(); + setStoreBackend({ supported: () => true } as unknown as StoreBackend); + + setNoOffline(true); + render(); + expect(screen.queryByTestId('wipe-device-section')).toBeNull(); + }); + it('is off until the user turns it on in this browser', () => { render(); const toggle = screen.getByTestId('offline-switch'); diff --git a/components/offline/__tests__/wipe-device-section.test.tsx b/components/offline/__tests__/wipe-device-section.test.tsx new file mode 100644 index 000000000..d07d03149 --- /dev/null +++ b/components/offline/__tests__/wipe-device-section.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx index d58d07a1d..80666f60c 100644 --- a/components/offline/account-storage.tsx +++ b/components/offline/account-storage.tsx @@ -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; )} + + openLocalExport(account)} data-testid={testId('local-export-open')}>{tExport('settings_button')} + {tStorage('clear_button')} diff --git a/components/offline/device-wipe-host.tsx b/components/offline/device-wipe-host.tsx new file mode 100644 index 000000000..405e94a82 --- /dev/null +++ b/components/offline/device-wipe-host.tsx @@ -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; +} diff --git a/components/offline/offline-settings.tsx b/components/offline/offline-settings.tsx index 5271e670f..1bfd3706a 100644 --- a/components/offline/offline-settings.tsx +++ b/components/offline/offline-settings.tsx @@ -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)) && } > )} diff --git a/components/offline/wipe-device-section.tsx b/components/offline/wipe-device-section.tsx new file mode 100644 index 000000000..d620843cf --- /dev/null +++ b/components/offline/wipe-device-section.tsx @@ -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(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 ( + + + {t('settings_title')} + {t('settings_description')} + + { void ask(); }} + data-testid="wipe-device-button" + > + {wiping ? t('settings_wiping') : t('settings_button')} + + setAsking(false)} + onConfirm={confirmed} + title={t('confirm_title')} + message={t('confirm_message')} + details={( + + {t('confirm_mail')} + {unsent === null && {t('confirm_unsent_unknown')}} + {unsent !== null && unsent > 0 && {t('confirm_unsent', { count: unsent })}} + {t('confirm_keys')} + {t('confirm_plugins')} + {t('confirm_files')} + {t('confirm_templates')} + {t('confirm_accounts')} + + )} + confirmText={t('confirm_button')} + variant="destructive" + /> + + ); +} diff --git a/components/plugins/app-side-panel.tsx b/components/plugins/app-side-panel.tsx index 2db5e2829..35ce18c5b 100644 --- a/components/plugins/app-side-panel.tsx +++ b/components/plugins/app-side-panel.tsx @@ -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', diff --git a/components/plugins/plugin-side-panels.tsx b/components/plugins/plugin-side-panels.tsx index b81d389e2..d6d4bee15 100644 --- a/components/plugins/plugin-side-panels.tsx +++ b/components/plugins/plugin-side-panels.tsx @@ -142,6 +142,7 @@ export function PluginSidePanel() { {active && (
{error}
+ {search ? 'No device matches that search.' : 'No device has registered yet.'} +
+ {group.user} + {group.server} +
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.{' '} How offline mode works diff --git a/components/boot-skeleton/admin-skeleton.tsx b/components/boot-skeleton/admin-skeleton.tsx index 16bc29d97..0db2ff49b 100644 --- a/components/boot-skeleton/admin-skeleton.tsx +++ b/components/boot-skeleton/admin-skeleton.tsx @@ -227,6 +227,9 @@ export function AdminSkeleton({ label, page, backLabel, searchLabel, labels, cla
{alert.title || tAlert("alert_title")}
+ {format.format(new Date(alert.startMs ?? alert.fireTimeMs))} + {alert.calendarName ? ` · ${alert.calendarName}` : ""} +
{t("detail.no_contact_selected")}
{tPolicy("export_blocked")}
{t("stopped_offline_off")}
{t("nothing_kept")}
{t("leftover_file", { name: leftover })}
{t("not_kept_attachments_off", { count: notKept.attachments_off })}
{t("not_kept_not_kept", { count: notKept.not_kept })}
{reachable ? t("retry_online") : t("retry_offline")}
{t("load_failed")}
{t("loading")}
{t("kept", { kept, total })}
+ {reachable ? t("missing_online", { count: missing }) : t("missing_offline", { count: missing })} +
{attachmentsOff ? t("reason_attachments_off") : t("reason_not_kept")}
{t("over_limit", { limit: formatFileSize(limit), size: formatFileSize(needed) })}
{t("no_room", { size: formatFileSize(needed), free: formatFileSize(loaded?.free ?? 0) })}
{refused}
{tExport(`save_${mode}`)}
{t('settings_title')}
{t('settings_description')}