diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 992bef2ec..b552b240d 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -27,6 +27,7 @@ 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 { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; @@ -115,6 +116,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/page.tsx b/app/(main)/[locale]/login/page.tsx index 4b1f3f439..fa1d82e00 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); @@ -1498,6 +1509,19 @@ function LoginPageContent() { {/* Form section */}
+ {wipeLost !== null && ( +
+ {wipeNoteText(wipeLost, tOfflineWipe)} +
+ )} + {refusedWaiting.map((wait) => { + const date = new Date(wait.until).toLocaleDateString(String(params?.locale ?? "en"), { dateStyle: "medium" }); + return ( +
+ {wait.unsent === null ? tOfflineWipe("refused_waiting_unknown", { date }) : tOfflineWipe("refused_waiting", { count: wait.unsent, date })} +
+ ); + })} {outboxLost.length > 0 && (
{tOffline("signout.lost", { subjects: outboxLost.map((s) => s || tOffline("outbox.no_subject")).join(", ") })} 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..db4ef63fa --- /dev/null +++ b/app/(main)/admin/_pages/device-registry-panel.tsx @@ -0,0 +1,250 @@ +'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" + /> + +
+ {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 && ( + + )} +
+ + {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 && ( + + )} + {device.wipe && ( + + )} + +
+ )} + /> + ))} +
+
+ ); + })} +
+ {total > PAGE_SIZE && ( +
+ {offset + 1} to {Math.min(offset + PAGE_SIZE, total)} of {total} + + + + +
+ )} + 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'; + 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() { + + + +
+ +
+ draft.setOrgValue('devices', 'wipeOnRefusedLogin', v)} testId="org-devices-wipeOnRefusedLogin" /> + draft.clearOrgValue('devices', 'wipeOnRefusedLogin')} /> +
+

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 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/device-wipe-host.tsx b/components/offline/device-wipe-host.tsx new file mode 100644 index 000000000..3816ca38d --- /dev/null +++ b/components/offline/device-wipe-host.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useTranslations } from 'next-intl'; +import { consumeWipeNote, 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]); + + 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')}

+
+ + 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/integration/tests/24-do-remote-wipe.spec.ts b/integration/tests/24-do-remote-wipe.spec.ts new file mode 100644 index 000000000..e70d36270 --- /dev/null +++ b/integration/tests/24-do-remote-wipe.spec.ts @@ -0,0 +1,382 @@ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { ENT, ENT_PASSWORD, adminContext, contextOptions, deliver, requireEntEnv, shot, userPage } from './helpers/ent'; +import { JMAP_URL } from './helpers/config'; + +/** + * Remote wipe, through the real UI on a production build, on both VM 106 + * Stalwarts, with the ent-* accounts: + * - a browser with offline on registers; the admin wipes it from + * Organisation > Devices; at its next connect the raw OPFS files, the key + * database, plugin databases and the handoff cache are gone, sign-in is + * required, the login page gives the count of lost mail, the instance has the + * confirmation and the audit log has both entries; + * - at a start the wipe comes before anything from the device is shown; + * - with the instance out of reach mail stays and the wipe keeps waiting, and it + * happens once the device is back; + * - "Remove everything from this device" in settings. + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(300_000); + +const RUN = Date.now().toString(36); + +interface Device { + id: string; + user: string; + platform: string; + firstSeen: string; + wipe: { requestedAt: string } | null; + wipedAt: string | null; + wipeReason: string | null; +} + +async function devicesOf(admin: BrowserContext, email: string): Promise { + const res = await admin.request.get(`/api/admin/devices?q=${encodeURIComponent(email)}`); + expect(res.ok(), `listing devices: ${res.status()}`).toBe(true); + return (await res.json() as { devices: Device[] }).devices; +} + +/** Rows earlier runs left. */ +async function forgetDevices(admin: BrowserContext, email: string): Promise { + for (const device of await devicesOf(admin, email)) { + const res = await admin.request.post('/api/admin/devices', { data: { action: 'forget', id: device.id } }); + expect(res.ok()).toBe(true); + } +} + +async function theDevice(admin: BrowserContext, email: string): Promise { + let found: Device | undefined; + await expect.poll(async () => { + found = (await devicesOf(admin, email))[0]; + return found?.id ?? ''; + }, { timeout: 60_000, message: `${email} registers` }).not.toBe(''); + return found!; +} + +async function adminWipe(admin: BrowserContext, id: string): Promise { + const res = await admin.request.post('/api/admin/devices', { data: { action: 'wipe', id } }); + expect(res.ok()).toBe(true); +} + +async function enableOffline(page: Page): Promise { + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 }); +} + +async function toMail(page: Page, subject: string): Promise { + await page.goto('/mail'); + await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 }); +} + +async function seedMail(to: string, subject: string): Promise { + await deliver({ from: ENT.bob, to: [to], subject, text: `Body of ${subject}` }); +} + +/** What's in the browser's storage for this origin, read raw, opening nothing that would create it. */ +async function rawStorage(page: Page): Promise<{ databases: string[]; storeFiles: string[]; caches: string[]; keyRecords: number }> { + return page.evaluate(async () => { + const databases = (await indexedDB.databases()).map((database) => database.name ?? ''); + const storeFiles: string[] = []; + const root = await navigator.storage.getDirectory(); + const list = async (dir: FileSystemDirectoryHandle, prefix: string): Promise => { + for await (const handle of (dir as unknown as { values(): AsyncIterable }).values()) { + if (handle.kind === 'directory') await list(handle as FileSystemDirectoryHandle, `${prefix}${handle.name}/`); + else storeFiles.push(`${prefix}${handle.name}`); + } + }; + await list(root, ''); + let keyRecords = 0; + if (databases.includes('bulwark-store')) { + keyRecords = await new Promise((resolve) => { + const open = indexedDB.open('bulwark-store'); + open.onerror = () => resolve(-1); + open.onsuccess = () => { + const db = open.result; + const count = db.transaction('accounts').objectStore('accounts').count(); + count.onsuccess = () => { db.close(); resolve(count.result); }; + count.onerror = () => { db.close(); resolve(-1); }; + }; + }); + } + return { databases, storeFiles, caches: await caches.keys(), keyRecords }; + }); +} + +async function expectStoreGone(page: Page): Promise { + await expect.poll(async () => { + const raw = await rawStorage(page); + return { + keys: raw.keyRecords, + store: raw.databases.includes('bulwark-store'), + legacy: raw.databases.includes('bulwark-offline'), + files: raw.storeFiles, + }; + }, { timeout: 30_000, message: 'nothing of the store is left' }).toEqual({ keys: 0, store: false, legacy: false, files: [] }); +} + +async function expectSignedOut(page: Page): Promise { + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); + const cookies = (await page.context().cookies()).map((cookie) => cookie.name); + expect(cookies.filter((name) => /^jmap_(session|stalwart_ctx|rt|at)/.test(name))).toEqual([]); + // And it stays that way. + await page.reload(); + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); +} + +test.beforeAll(() => { + requireEntEnv(); +}); + +test('the admin wipes a device: at its next connect everything on it goes, and the instance hears', async ({ browser }) => { + const subject = `Wipe me ${RUN}`; + const unsent = `Unsent secret ${RUN}`; + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.dave); + await forgetDevices(admin, ENT.alice); + await seedMail(ENT.dave, subject); + + // A second user, so the page shows what an admin really sees. + const other = await userPage(browser, ENT.alice); + await enableOffline(other); + const otherDevice = await theDevice(admin, ENT.alice); + + const page = await userPage(browser, ENT.dave); + await enableOffline(page); + await toMail(page, subject); + const device = await theDevice(admin, ENT.dave); + expect(device.user).toBe(ENT.dave); + expect(device.platform).toContain('Chrom'); + expect(device.wipe).toBeNull(); + + const before = await rawStorage(page); + expect(before.keyRecords).toBe(1); + expect(before.storeFiles.length).toBeGreaterThan(0); + + // What plugins and the launch handoff would have left. + await page.evaluate(async (run) => { + for (const name of ['smime-plugin-store', 'bulwark-openpgp', `plugin-made-${run}`]) { + await new Promise((resolve, reject) => { + const open = indexedDB.open(name, 1); + open.onupgradeneeded = () => { open.result.createObjectStore('keys'); }; + open.onsuccess = () => { open.result.close(); resolve(); }; + open.onerror = () => reject(open.error); + }); + } + const cache = await caches.open('bulwark-launch-handoff-v1'); + await cache.put('/handoff-test', new Response('secret file')); + }, RUN); + + // A message waits in the outbox, so the note has something to count. + await page.context().setOffline(true); + await page.keyboard.press('c'); + const composer = page.locator('[data-testid="email-composer"]'); + await composer.waitFor({ state: 'visible', timeout: 15_000 }); + const to = page.locator('[data-testid="composer-to"] input').first(); + await to.click(); + await to.fill(ENT.bob); + await to.press('Enter'); + await page.locator('[data-testid="composer-subject"]').first().fill(unsent); + await composer.locator('[contenteditable="true"]').first().click(); + await page.keyboard.type('Never sent'); + await page.locator('[data-testid="composer-subject"]').first().focus(); + await page.keyboard.press('Control+Enter'); + await expect(page.getByTestId('offline-banner-outbox')).toHaveText('1 message in the outbox', { timeout: 30_000 }); + + // The admin's side, through the page. + const adminPage = await admin.newPage(); + await adminPage.goto('/admin/org-devices'); + const section = adminPage.locator('[data-user="ent-dave@example.org"]'); + await expect(section.getByTestId('device-row')).toBeVisible({ timeout: 30_000 }); + await expect(adminPage.locator('[data-user="ent-alice@example.org"]').getByTestId('device-row')).toBeVisible(); + await section.scrollIntoViewIfNeeded(); + await shot(adminPage, 'desktop-offline-admin-devices'); + await section.getByTestId('device-wipe').click(); + await expect(adminPage.getByRole('dialog').or(adminPage.getByRole('alertdialog')).first()).toContainText('imported in that browser or app go for every account'); + await adminPage.getByRole('button', { name: 'Wipe', exact: true }).last().click(); + await expect(section.getByTestId('device-row')).toContainText('Wipe waiting', { timeout: 15_000 }); + await shot(adminPage, 'desktop-offline-admin-devices-waiting'); + expect((await devicesOf(admin, ENT.dave))[0].wipe).not.toBeNull(); + + // Back on the network, with the mail server gone: the word comes from the + // instance and needs nothing of the mail server. The outbox can't leave either. + await page.context().route(`${new URL(JMAP_URL).origin}/**`, (route) => route.abort('internetdisconnected')); + await page.context().setOffline(false); + + await expect(page.locator('#username')).toBeVisible({ timeout: 90_000 }); + await expect(page.getByTestId('device-wipe-note')).toContainText('Unsent messages deleted: 1'); + expect(await page.getByTestId('device-wipe-note').textContent()).not.toContain(unsent); + + await expectStoreGone(page); + const raw = await rawStorage(page); + expect(raw.databases).not.toContain('smime-plugin-store'); + expect(raw.databases).not.toContain('bulwark-openpgp'); + expect(raw.databases).not.toContain(`plugin-made-${RUN}`); + expect(raw.caches).not.toContain('bulwark-launch-handoff-v1'); + const stored = await page.evaluate(() => JSON.stringify({ ...localStorage }) + JSON.stringify({ ...sessionStorage })); + expect(stored).not.toContain(unsent); + await page.context().unroute(`${new URL(JMAP_URL).origin}/**`); + + await expectSignedOut(page); + + // The note shows once. + await expect(page.getByTestId('device-wipe-note')).toHaveCount(0); + + const confirmed = (await devicesOf(admin, ENT.dave))[0]; + expect(confirmed.id).toBe(device.id); + expect(confirmed.wipe).toBeNull(); + expect(confirmed.wipedAt).not.toBeNull(); + expect(confirmed.wipeReason).toBe('admin'); + + // The other user's row isn't touched. + expect((await devicesOf(admin, ENT.alice)).find((row) => row.id === otherDevice.id)?.wipe).toBeNull(); + + const audit = async (action: string) => { + const res = await admin.request.get(`/api/admin/audit?action=${action}&limit=50`); + expect(res.ok()).toBe(true); + return (await res.json() as { entries: Array<{ detail: Record }> }).entries.filter((entry) => entry.detail.device === device.id); + }; + expect(await audit('device.wipe.request')).toHaveLength(1); + const confirmations = await audit('device.wipe.confirm'); + expect(confirmations).toHaveLength(1); + expect(confirmations[0].detail).toMatchObject({ user: ENT.dave, reason: 'admin', unsentDeleted: 1 }); + + await adminPage.reload(); + await expect(adminPage.locator('[data-user="ent-dave@example.org"]').getByTestId('device-row')).toContainText('Wiped', { timeout: 30_000 }); + + // A new sign-in is a new device. + await page.fill('#username', ENT.dave); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await expect(page.locator('#username')).toBeHidden({ timeout: 60_000 }); + await enableOffline(page); + await expect.poll(async () => (await devicesOf(admin, ENT.dave)).length, { timeout: 60_000 }).toBe(2); + + await Promise.all([page.context().close(), other.context().close(), admin.close()]); +}); + +test('at a start, the wipe comes before anything from the device is shown', async ({ browser }) => { + const subject = `Start wipe ${RUN}`; + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.alice); + await seedMail(ENT.alice, subject); + const page = await userPage(browser, ENT.alice); + await enableOffline(page); + await toMail(page, subject); + const device = await theDevice(admin, ENT.alice); + const context = page.context(); + + // The mail was on screen here a moment ago, and comes from the device at a start. + await context.addInitScript(() => { + const look = () => { + if (document.querySelector('[data-testid="email-list-item"]')) sessionStorage.setItem('wipe-test-saw-mail', 'yes'); + }; + new MutationObserver(look).observe(document, { childList: true, subtree: true }); + }); + await page.close(); + await adminWipe(admin, device.id); + + const next = await context.newPage(); + await next.goto('/mail'); + await expect(next.locator('#username')).toBeVisible({ timeout: 60_000 }); + expect(await next.evaluate(() => sessionStorage.getItem('wipe-test-saw-mail'))).toBeNull(); + await expect(next.getByTestId('device-wipe-note')).toContainText('removed this account'); + await expectStoreGone(next); + await expect.poll(async () => (await devicesOf(admin, ENT.alice))[0].wipedAt, { timeout: 30_000 }).not.toBeNull(); + + await Promise.all([context.close(), admin.close()]); +}); + +test('with the instance out of reach the mail stays and the wipe waits, until the device is back', async ({ browser }) => { + const subject = `Waits ${RUN}`; + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.carol); + await seedMail(ENT.carol, subject); + const page = await userPage(browser, ENT.carol); + await enableOffline(page); + await toMail(page, subject); + const device = await theDevice(admin, ENT.carol); + await adminWipe(admin, device.id); + + // A start with no network at all: the local copy, as ever. + await page.context().setOffline(true); + await page.reload(); + await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 }); + expect((await rawStorage(page)).keyRecords).toBe(1); + + // The device never got the message, and the row keeps saying so. + const waiting = (await devicesOf(admin, ENT.carol))[0]; + expect(waiting.wipe).not.toBeNull(); + expect(waiting.wipedAt).toBeNull(); + + await page.context().setOffline(false); + await expect(page.locator('#username')).toBeVisible({ timeout: 90_000 }); + await expectStoreGone(page); + await expect.poll(async () => (await devicesOf(admin, ENT.carol))[0].wipedAt, { timeout: 30_000 }).not.toBeNull(); + + await Promise.all([page.context().close(), admin.close()]); +}); + +test('the user removes everything from this device, from settings', async ({ browser }) => { + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.bob); + const page = await userPage(browser, ENT.bob); + // Switched on and no more: this account has a lot of mail, so the wipe lands in + // the middle of its first sync, with the worker busy writing. + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + await expect.poll(async () => (await rawStorage(page)).keyRecords, { timeout: 60_000 }).toBe(1); + await expect(page.getByTestId('offline-status')).toBeVisible({ timeout: 30_000 }); + const device = await theDevice(admin, ENT.bob); + + await page.getByTestId('wipe-device-button').scrollIntoViewIfNeeded(); + await page.getByTestId('wipe-device-button').click(); + const details = page.getByTestId('wipe-device-details'); + await expect(details).toContainText('S/MIME and OpenPGP keys'); + await expect(details).toContainText('All accounts are signed out'); + await shot(page, 'desktop-offline-remove-everything'); + // Nothing has happened yet. + expect((await rawStorage(page)).keyRecords).toBe(1); + await page.getByRole('button', { name: 'Remove everything', exact: true }).last().click(); + + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); + await expectStoreGone(page); + await expectSignedOut(page); + await expect.poll(async () => (await devicesOf(admin, ENT.bob)).find((row) => row.id === device.id)?.wipeReason, { timeout: 30_000 }).toBe('user'); + + await Promise.all([page.context().close(), admin.close()]); +}); + +test('the sign-in page says what waits for a new password, in numbers', async ({ browser }) => { + const context = await browser.newContext(contextOptions()); + const page = await context.newPage(); + await page.goto('/login'); + await expect(page.locator('#username')).toBeVisible({ timeout: 30_000 }); + const at = Date.now(); + await page.evaluate((now) => { + localStorage.setItem('bulwark-offline-evicted', JSON.stringify({ 'someone@example.org': now })); + localStorage.setItem('bulwark-credentials-refused', JSON.stringify({ 'someone@example.org': { at: now, unsent: 3, certain: true } })); + }, at); + await page.reload(); + const note = page.getByTestId('login-refused-waiting'); + await expect(note).toBeVisible({ timeout: 30_000 }); + await expect(note).toContainText('3 unsent messages wait for your new password'); + await expect(note).not.toContainText('someone'); + await context.close(); +}); + +test('an account without offline mode has no row', async ({ browser }) => { + const admin = await adminContext(browser); + await forgetDevices(admin, ENT.carol); + const page = await userPage(browser, ENT.carol); + await page.waitForTimeout(3_000); + expect(await devicesOf(admin, ENT.carol)).toEqual([]); + await Promise.all([page.context().close(), admin.close()]); +}); diff --git a/lib/admin/__tests__/server-device-offline-caps.test.ts b/lib/admin/__tests__/server-device-offline-caps.test.ts index a6661ea25..1b9ba98e4 100644 --- a/lib/admin/__tests__/server-device-offline-caps.test.ts +++ b/lib/admin/__tests__/server-device-offline-caps.test.ts @@ -1,11 +1,24 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_POLICY, type SettingsPolicy } from '../types'; import { ALL_DEVICE_LIMITS, strictestOfAllScopes } from '../server-device-policy'; +import { sanitizeOrganisationPartial } from '../organisation-policy'; // The offline caps are numbers in a section that used to hold only booleans. const scope = (id: string, devices: Record) => ({ id, label: id, match: { domains: [`${id}.example.org`] }, policy: { organisation: { devices } } }); +describe('wipeOnRefusedLogin', () => { + it('is off unless asked for, and survives the sanitizer', () => { + expect(DEFAULT_POLICY.organisation?.devices?.wipeOnRefusedLogin ?? false).toBe(false); + expect(sanitizeOrganisationPartial({ devices: { wipeOnRefusedLogin: true } }).devices).toMatchObject({ wipeOnRefusedLogin: true }); + expect(sanitizeOrganisationPartial({ devices: { wipeOnRefusedLogin: 'yes' } }).devices?.wipeOnRefusedLogin).not.toBe(true); + }); + + it('isn\'t set by the stand-in for a login nobody can read: that\'s for what stays on the server', () => { + expect(ALL_DEVICE_LIMITS.wipeOnRefusedLogin).toBe(false); + }); +}); + describe('strictest of all scopes', () => { it('keeps the lowest cap, and a boolean switch still turns on', () => { const policy = { diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts index c0d5edf9a..b0c07c7ec 100644 --- a/lib/admin/admin-nav.ts +++ b/lib/admin/admin-nav.ts @@ -243,6 +243,7 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [ sections: [ { id: 'devices', title: 'Downloads, printing and notifications', keywords: 'download attachment export print watermark install prompt push sender subject' }, { id: 'offline', title: 'Offline mode', keywords: 'offline cache device keep mail outbox' }, + { id: 'registered', title: 'Registered devices', keywords: 'device registry wipe remote lost stolen laptop remove desktop app browser' }, ], }, { diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index b49c3f812..e8e6f5d4f 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -175,6 +175,12 @@ export interface OrganisationPolicy { offlineMaxAttachmentMb: number; /** All attachments and files of one account on a device, in MB. 0 is no cap. */ offlineMaxTotalMb: number; + /** + * A device deletes an account's offline data as soon as the server refuses its + * login, instead of hiding it for 30 days until the new password. Unsent mail + * goes with it. + */ + wipeOnRefusedLogin: boolean; }; calendars: { /** Adds up across scopes by id. */ @@ -200,6 +206,7 @@ export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = { noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false, pushWithoutContent: false, watermark: false, noOffline: false, offlineMaxDays: 0, noOfflineAttachments: false, offlineMaxAttachmentMb: 0, offlineMaxTotalMb: 0, + wipeOnRefusedLogin: false, }, calendars: { feeds: [], freeBusyGroup: '' }, }; @@ -544,6 +551,7 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial [key, typeof off === 'number' ? 1 : true]), -) as ServerDevicePolicy; +/** + * Everything on, and each cap as low as it goes: when not even the policy can be + * read. Except deleting a device's mail, which is no limit on anything the server does. + */ +export const ALL_DEVICE_LIMITS: ServerDevicePolicy = { + ...Object.fromEntries( + Object.entries(DEFAULT_ORGANISATION_POLICY.devices).map(([key, off]) => [key, typeof off === 'number' ? 1 : true]), + ), + wipeOnRefusedLogin: false, +} as ServerDevicePolicy; /** * The device policy that applies to this login. When its session can't be diff --git a/lib/devices/__tests__/client.test.ts b/lib/devices/__tests__/client.test.ts new file mode 100644 index 000000000..0ee6777c1 --- /dev/null +++ b/lib/devices/__tests__/client.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { checkInWithInstance, describePlatform, registerWithInstance, reportWipeToInstance } from '../client'; + +const record = { id: `${'a'.repeat(64)}/${'b'.repeat(32)}`, secret: 'c'.repeat(64) }; + +function answer(status: number, body: string, type = 'application/json'): void { + vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status, headers: { 'Content-Type': type } }))); +} + +afterEach(() => { + vi.unstubAllGlobals(); + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('a check-in', () => { + it('hears a wipe only from a clear yes', async () => { + answer(200, JSON.stringify({ wipe: { requestedAt: '2026-11-03T00:00:00Z' } })); + expect(await checkInWithInstance(record)).toBe('wipe'); + }); + + it('reads no wipe as clear', async () => { + answer(200, JSON.stringify({ wipe: null })); + expect(await checkInWithInstance(record)).toBe('clear'); + }); + + it('reads the route\'s own 404 as a forgotten row', async () => { + answer(404, JSON.stringify({ error: 'unknown_device' })); + expect(await checkInWithInstance(record)).toBe('forgotten'); + }); + + it.each([ + ['an older instance\'s 404', 404, 'Not found', 'text/html'], + ['a 500', 500, JSON.stringify({ error: 'Internal server error' }), 'application/json'], + ['a 503', 503, 'Bad gateway', 'text/html'], + ['a 200 from something else', 200, 'Login', 'text/html'], + ['a 200 with other JSON', 200, JSON.stringify({ ok: true }), 'application/json'], + ['a 401', 401, JSON.stringify({ wipe: { requestedAt: 'x' } }), 'application/json'], + ])('reads %s as nothing', async (_name, status, body, type) => { + answer(status, body, type); + expect(await checkInWithInstance(record)).toBe('unavailable'); + }); + + it('hears that its row was wiped', async () => { + answer(200, JSON.stringify({ wipe: null, wiped: true })); + expect(await checkInWithInstance({ id: 'r', secret: 's' })).toBe('wiped'); + }); + + it('reads a 429 as nothing', async () => { + answer(429, JSON.stringify({ error: 'rate limited' })); + expect(await checkInWithInstance({ id: 'r', secret: 's' })).toBe('unavailable'); + }); + + it('reads no network as nothing', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + expect(await checkInWithInstance(record)).toBe('unavailable'); + }); +}); + +describe('registering', () => { + it('names the account\'s cookie slot', async () => { + const row = { ...record, user: 'a@b.c', server: 'https://b.c' }; + const fetchMock = vi.fn(async () => new Response(JSON.stringify(row), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + expect(await registerWithInstance(3)).toEqual(row); + const init = (fetchMock.mock.calls as unknown as [string, RequestInit][])[0][1]; + expect((init.headers as Record)['X-JMAP-Cookie-Slot']).toBe('3'); + }); + + it('hands back the owner the instance gave', async () => { + answer(200, JSON.stringify({ id: 'r', secret: 's', user: 'a@b.c', server: 'https://b.c' })); + expect(await registerWithInstance(0)).toEqual({ id: 'r', secret: 's', user: 'a@b.c', server: 'https://b.c' }); + }); + + it('won\'t take a row with no owner', async () => { + answer(200, JSON.stringify({ id: 'r', secret: 's' })); + expect(await registerWithInstance(0)).toBeNull(); + }); + + it('gives null when it can\'t', async () => { + answer(401, '{}'); + expect(await registerWithInstance(0)).toBeNull(); + }); +}); + +describe('reporting a wipe', () => { + const entry = { ...record, reason: 'admin' as const, unsent: 1 }; + + it('is done on a yes, and when the row is gone', async () => { + answer(200, JSON.stringify({ ok: true })); + expect(await reportWipeToInstance(entry)).toBe(true); + answer(404, JSON.stringify({ error: 'unknown_device' })); + expect(await reportWipeToInstance(entry)).toBe(true); + }); + + it('tries again after anything else', async () => { + answer(500, '{}'); + expect(await reportWipeToInstance(entry)).toBe(false); + answer(404, ''); + expect(await reportWipeToInstance(entry)).toBe(false); + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); })); + expect(await reportWipeToInstance(entry)).toBe(false); + }); +}); + +describe('a confirmation the instance turned down', () => { + it('is done when no wipe was asked: sending it again can\'t change that', async () => { + answer(409, JSON.stringify({ error: 'no_wipe_asked' })); + expect(await reportWipeToInstance({ id: 'r', secret: 's', reason: 'admin', unsent: 0 })).toBe(true); + }); + + it('tries again after a 429', async () => { + answer(429, JSON.stringify({ error: 'rate limited' })); + expect(await reportWipeToInstance({ id: 'r', secret: 's', reason: 'admin', unsent: 0 })).toBe(false); + }); + + it('sends the row, the reason and the count, and no account name', async () => { + answer(200, JSON.stringify({ ok: true })); + await reportWipeToInstance({ id: 'r', secret: 's', reason: 'admin', unsent: null, account: 'alice@example.org' }); + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body))).toEqual({ id: 'r', secret: 's', reason: 'admin', unsent: null }); + }); + + it('a row dropped at sign-out says so', async () => { + answer(200, JSON.stringify({ ok: true })); + await reportWipeToInstance({ id: 'r', secret: 's', reason: 'user', unsent: 0, drop: true }); + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0][1]?.body))).toEqual({ id: 'r', secret: 's', drop: true }); + }); +}); + +describe('what a row calls the device', () => { + it('names the desktop app', () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; + expect(describePlatform()).toBe('Desktop app, Windows'); + }); + + it('names a browser', () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/120.0 Safari/537.36'); + expect(describePlatform()).toBe('Chrome, Windows'); + }); +}); diff --git a/lib/devices/__tests__/registry.test.ts b/lib/devices/__tests__/registry.test.ts new file mode 100644 index 000000000..f99b41d27 --- /dev/null +++ b/lib/devices/__tests__/registry.test.ts @@ -0,0 +1,249 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fakeSharedStore } from '@/lib/shared-store/__tests__/fake-shared'; +import { setStoreForTests } from '@/lib/shared-store'; + +type Registry = typeof import('../registry'); + +vi.setConfig({ testTimeout: 60_000 }); + +let dir: string; +const info = { platform: 'Chrome, Windows', appVersion: '1.2.3' }; + +/** A second server process: its own copy of every module, the one store in common. */ +async function instance(shared: ReturnType): Promise { + vi.resetModules(); + const store = await import('@/lib/shared-store'); + store.setStoreForTests(shared); + return import('../registry'); +} + +beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'bw-devices-')); + vi.stubEnv('ADMIN_STATE_DIR', dir); +}); + +afterEach(() => { + setStoreForTests(null); + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('two server instances sharing the store', () => { + it('see one registry: a wipe asked on one reaches the device through the other, and the confirmation comes back', async () => { + const shared = fakeSharedStore(); + const one = await instance(shared); + const two = await instance(shared); + expect(two).not.toBe(one); + + const { id, secret } = await one.registerDevice('alice@example.org', 'https://mail.example.org', info); + const listed = await two.listDevices(); + expect(listed.total).toBe(1); + expect(listed.devices[0]).toMatchObject({ id, user: 'alice@example.org', platform: 'Chrome, Windows', appVersion: '1.2.3', wipe: null }); + + expect((await one.checkInDevice(id, secret, info))?.wipe).toBeNull(); + await two.requestDeviceWipe(id, 'admin'); + expect((await one.checkInDevice(id, secret, info))?.wipe).toMatchObject({ by: 'admin' }); + + await one.confirmDeviceWipe(id, secret, 'admin'); + const after = (await two.listDevices()).devices[0]; + expect(after.wipe).toBeNull(); + expect(after.wipedAt).not.toBeNull(); + expect(after.wipeReason).toBe('admin'); + }); + + it('keeps writes from both when they answer at once', async () => { + const shared = fakeSharedStore(); + const one = await instance(shared); + const two = await instance(shared); + const { id, secret } = await one.registerDevice('alice@example.org', 'https://mail.example.org', info); + await Promise.all([ + two.requestDeviceWipe(id, 'admin'), + one.checkInDevice(id, secret, { ...info, appVersion: '1.2.4' }), + ]); + const device = await two.getDevice(id); + expect(device?.wipe).toMatchObject({ by: 'admin' }); + expect(device?.appVersion).toBe('1.2.4'); + }); +}); + +describe('a local store', () => { + it('keeps a pending wipe through a restart, in the state dir, with no expiry', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(id, 'admin'); + expect(registry.DEVICES_COLLECTION.ttlMs).toBeUndefined(); + + // A new process reads the file. + vi.resetModules(); + const restarted: Registry = await import('../registry'); + expect((await restarted.checkInDevice(id, secret, info))?.wipe).toMatchObject({ by: 'admin' }); + expect(readdirSync(path.join(dir, 'devices'), { recursive: true }).length).toBeGreaterThan(0); + }); + + it('stores only a hash of the secret, and never shows that either', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const file = readFileSync(path.join(dir, 'devices', `${id}.json`), 'utf8'); + expect(file).not.toContain(secret); + expect(JSON.stringify(await registry.listDevices())).not.toContain('secretHash'); + }); +}); + +describe('the rules', () => { + it('a wrong secret reads as no such device and changes nothing', async () => { + const registry: Registry = await import('../registry'); + const { id } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(id, 'admin'); + expect(await registry.checkInDevice(id, 'f'.repeat(64), info)).toBeNull(); + expect(await registry.confirmDeviceWipe(id, 'f'.repeat(64), 'admin')).toBeNull(); + expect((await registry.getDevice(id))?.wipe).not.toBeNull(); + }); + + it('a device that never answers keeps its pending wipe, also past the age where others go', async () => { + const registry: Registry = await import('../registry'); + const waiting = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const idle = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(waiting.id, 'admin'); + + const later = Date.now() + registry.DEVICE_PRUNE_MS + 1000; + const { devices } = await registry.listDevices({ now: later }); + expect(devices.map((device) => device.id)).toEqual([waiting.id]); + expect(await registry.getDevice(idle.id)).toBeNull(); + expect((await registry.getDevice(waiting.id))?.wipe).not.toBeNull(); + }); + + it('one user\'s wipe reaches all of that user\'s devices and no one else\'s', async () => { + const registry: Registry = await import('../registry'); + const first = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const second = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const other = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + + const asked = await registry.requestUserWipe(first.id.split('/')[0], 'admin'); + expect(asked.map((device) => device.id).sort()).toEqual([first.id, second.id].sort()); + expect((await registry.getDevice(other.id))?.wipe).toBeNull(); + }); + + it('asks only once, and not of a device that already confirmed', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + expect(await registry.requestDeviceWipe(id, 'admin')).not.toBeNull(); + expect(await registry.requestDeviceWipe(id, 'admin')).toBeNull(); + await registry.confirmDeviceWipe(id, secret, 'admin'); + expect(await registry.requestDeviceWipe(id, 'admin')).toBeNull(); + }); + + it('a wipe taken back no longer reaches the device', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(id, 'admin'); + await registry.cancelDeviceWipe(id); + expect((await registry.checkInDevice(id, secret, info))?.wipe).toBeNull(); + }); + + it('a check-in soon after the last one doesn\'t write again, a later one does', async () => { + const registry: Registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const first = (await registry.getDevice(id))!.lastSeen; + const soon = await registry.checkInDevice(id, secret, info, Date.parse(first) + 1000); + expect(soon?.lastSeen).toBe(first); + const later = await registry.checkInDevice(id, secret, info, Date.parse(first) + 60 * 60 * 1000); + expect(later?.lastSeen).not.toBe(first); + }); + + it('caps a user\'s rows by dropping the oldest without a wipe waiting', async () => { + const registry: Registry = await import('../registry'); + const oldest = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(oldest.id, 'admin'); + const next = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + for (let i = 2; i < registry.MAX_DEVICES_PER_USER; i += 1) { + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + } + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const ids = (await registry.listDevices({ limit: 200 })).devices.map((device) => device.id); + expect(ids).toHaveLength(registry.MAX_DEVICES_PER_USER); + expect(ids).toContain(oldest.id); + expect(ids).not.toContain(next.id); + }); + + it('tells a confirmation that changed the row from one that didn\'t', async () => { + const registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(id, 'admin'); + const first = await registry.confirmDeviceWipe(id, secret, 'admin'); + expect(first).toMatchObject({ changed: true }); + const again = await registry.confirmDeviceWipe(id, secret, 'admin'); + expect(again).toMatchObject({ changed: false }); + }); + + it('won\'t take a confirmation from the admin without a request', async () => { + const registry = await import('../registry'); + const { id, secret } = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + expect(await registry.confirmDeviceWipe(id, secret, 'admin')).toBe('no_wipe_asked'); + expect((await registry.getDevice(id))?.wipedAt).toBeNull(); + }); + + it('prunes a user\'s abandoned rows when a new one registers, not ones with a wipe waiting', async () => { + const registry = await import('../registry'); + const start = Date.now() + 365 * 24 * 60 * 60 * 1000; + vi.useFakeTimers({ now: start, toFake: ['Date'] }); + const old = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const waiting = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(waiting.id, 'admin'); + vi.setSystemTime(start + registry.DEVICE_PRUNE_MS + 60_000); + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + vi.useRealTimers(); + expect(await registry.getDevice(old.id)).toBeNull(); + expect(await registry.getDevice(waiting.id)).not.toBeNull(); + }); + + it('prunes the user\'s own rows also in the hour after the registry-wide look', async () => { + const registry = await import('../registry'); + const start = Date.now() + 2 * 365 * 24 * 60 * 60 * 1000; + vi.useFakeTimers({ now: start, toFake: ['Date'] }); + const old = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + // Someone else's registration has the look just before the row goes stale. + vi.setSystemTime(start + registry.DEVICE_PRUNE_MS - 30 * 60 * 1000); + await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + vi.setSystemTime(start + registry.DEVICE_PRUNE_MS + 60_000); + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + vi.useRealTimers(); + expect(await registry.getDevice(old.id)).toBeNull(); + }); + + it('abandoned rows expire with no admin looking, others\' too, through any registration', async () => { + const registry = await import('../registry'); + const start = Date.now() + 4 * 365 * 24 * 60 * 60 * 1000; + vi.useFakeTimers({ now: start, toFake: ['Date'] }); + const abandoned = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + const waiting = await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + await registry.requestDeviceWipe(waiting.id, 'admin'); + vi.setSystemTime(start + registry.DEVICE_PRUNE_MS + 2 * 60 * 60 * 1000); + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + vi.useRealTimers(); + expect(await registry.getDevice(abandoned.id)).toBeNull(); + expect(await registry.getDevice(waiting.id)).not.toBeNull(); + }); + + it('keeps the cap with every row waiting for a wipe: a new row is refused, the wipes stay', async () => { + const registry = await import('../registry'); + const first = await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + const userKey = first.id.split('/')[0]; + for (let i = 1; i < registry.MAX_DEVICES_PER_USER; i += 1) await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + expect(await registry.requestUserWipe(userKey, 'admin')).toHaveLength(registry.MAX_DEVICES_PER_USER); + await expect(registry.registerDevice('alice@example.org', 'https://mail.example.org', info)).rejects.toThrow(registry.TooManyDevicesError); + expect((await registry.listDevices({ limit: 200 })).total).toBe(registry.MAX_DEVICES_PER_USER); + expect((await registry.getDevice(first.id))?.wipe).not.toBeNull(); + }); + + it('finds a user by part of the address', async () => { + const registry: Registry = await import('../registry'); + await registry.registerDevice('alice@example.org', 'https://mail.example.org', info); + await registry.registerDevice('bob@example.org', 'https://mail.example.org', info); + const { devices, total } = await registry.listDevices({ query: 'ALICE' }); + expect(total).toBe(1); + expect(devices[0].user).toBe('alice@example.org'); + }); +}); diff --git a/lib/devices/client.ts b/lib/devices/client.ts new file mode 100644 index 000000000..2161b3724 --- /dev/null +++ b/lib/devices/client.ts @@ -0,0 +1,102 @@ +/** + * This device's calls to the instance's registry. Only a clear answer counts: + * a wipe needs a 200 with a `wipe` in it, and "forgotten" needs the route's own + * word. An older instance without the routes, a proxy's error page or no + * network at all reads as `unavailable`, and nothing happens. + */ + +import { apiFetch } from '@/lib/browser-navigation'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { DeviceRecord } from '@/lib/store/client/keys'; +import type { WipeConfirmation } from './wipe-state'; + +export type CheckInAnswer = 'wipe' | 'wiped' | 'clear' | 'forgotten' | 'unavailable'; + +/** A new row and who the instance gave it to: the device checks that's the account it asked for. */ +export interface RegisteredRow { + id: string; + secret: string; + user: string; + server: string; +} + +/** Short enough for one row in a list. Nothing identifying beyond what a browser sends anyway. */ +export function describePlatform(): string { + const bridge = getDesktopBridge(); + if (bridge) return `Desktop app, ${({ win32: 'Windows', darwin: 'macOS', linux: 'Linux' } as const)[bridge.platform] ?? bridge.platform}`; + if (typeof navigator === 'undefined') return ''; + const ua = navigator.userAgent; + const os = /Android/.test(ua) ? 'Android' + : /iPhone|iPad/.test(ua) ? 'iOS' + : /Windows/.test(ua) ? 'Windows' + : /Mac OS X/.test(ua) ? 'macOS' + : /Linux/.test(ua) ? 'Linux' + : 'Unknown system'; + const browser = /Edg\//.test(ua) ? 'Edge' + : /Firefox\//.test(ua) ? 'Firefox' + : /Chrome\//.test(ua) ? 'Chrome' + : /Safari\//.test(ua) ? 'Safari' + : 'Browser'; + return `${browser}, ${os}`; +} + +function deviceInfo() { + return { platform: describePlatform(), appVersion: process.env.NEXT_PUBLIC_APP_VERSION ?? '' }; +} + +async function readJson(response: Response): Promise | null> { + try { + const body = await response.clone().json() as unknown; + return body && typeof body === 'object' ? body as Record : null; + } catch { + return null; + } +} + +function post(path: string, body: unknown, signal?: AbortSignal, headers: Record = {}): Promise { + return apiFetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + signal, + }); +} + +/** A new row, for the account signed in on this cookie slot. Null if that can't be done now. */ +export async function registerWithInstance(slot: number, signal?: AbortSignal): Promise { + try { + const response = await post('/api/devices', deviceInfo(), signal, { 'X-JMAP-Cookie-Slot': String(slot) }); + if (!response.ok) return null; + const body = await readJson(response); + if (typeof body?.id !== 'string' || typeof body.secret !== 'string' || typeof body.user !== 'string' || typeof body.server !== 'string') return null; + return { id: body.id, secret: body.secret, user: body.user, server: body.server }; + } catch { + return null; + } +} + +export async function checkInWithInstance(record: Pick, signal?: AbortSignal): Promise { + try { + const response = await post('/api/devices', { id: record.id, secret: record.secret, ...deviceInfo() }, signal); + const body = await readJson(response); + if (response.status === 404) return body?.error === 'unknown_device' ? 'forgotten' : 'unavailable'; + if (!response.ok || !body || !('wipe' in body)) return 'unavailable'; + if (body.wipe && typeof body.wipe === 'object') return 'wipe'; + return body.wiped === true ? 'wiped' : 'clear'; + } catch { + return 'unavailable'; + } +} + +/** True when there's nothing more to say: it was taken, or the row no longer exists. */ +export async function reportWipeToInstance(entry: WipeConfirmation, signal?: AbortSignal): Promise { + try { + const { id, secret, reason, unsent, drop } = entry; + const response = await post('/api/devices/wiped', drop ? { id, secret, drop: true } : { id, secret, reason, unsent }, signal); + if (response.ok) return true; + const error = (await readJson(response))?.error; + return (response.status === 404 && error === 'unknown_device') || (response.status === 409 && error === 'no_wipe_asked'); + } catch { + return false; + } +} diff --git a/lib/devices/registry.ts b/lib/devices/registry.ts new file mode 100644 index 000000000..c69d95813 --- /dev/null +++ b/lib/devices/registry.ts @@ -0,0 +1,285 @@ +/** + * The devices that keep mail for this instance: browsers with offline mode on + * and desktop apps. One durable document per device and account, so every + * replica sees the same list and a pending wipe outlives a restart. + * + * No `ttlMs`: a machine that stays off for a year still has its wipe waiting. + * Rows nobody has seen for half a year and with no wipe waiting go when a new + * row registers (at most hourly for the whole registry) or an admin lists them. + * + * A check-in proves itself with the device's secret, not with a session, and + * nothing here asks the mail server. A device whose password was revoked has + * to be able to learn about its wipe. The secret only lets a device read and + * answer for its own row. + */ + +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { getStatePath } from '@/lib/admin/paths'; +import { settingsId } from '@/lib/settings-sync'; +import { getStore, type CollectionOptions } from '@/lib/shared-store'; +import type { DeviceView, WipeReason } from './types'; + +export const DEVICE_ID_RE = /^[0-9a-f]{64}\/[0-9a-f]{32}$/; +export const DEVICE_SECRET_RE = /^[0-9a-f]{64}$/; +const USER_KEY_RE = /^[0-9a-f]{64}$/; +const DEVICE_FILE_RE = /^[0-9a-f]{64}\/[0-9a-f]{32}\.json$/; + +/** A user with more rows than this loses the oldest one that has no wipe waiting. */ +export const MAX_DEVICES_PER_USER = 100; +/** Not seen for this long and no wipe waiting: abandoned, and forgotten. */ +export const DEVICE_PRUNE_MS = 180 * 24 * 60 * 60 * 1000; +/** A check-in within this time of the last write doesn't write again. */ +const LAST_SEEN_WRITE_MS = 5 * 60 * 1000; +const READ_CONCURRENCY = 50; +const PRUNE_EVERY_MS = 60 * 60 * 1000; + +export const DEVICES_COLLECTION: CollectionOptions = { + local: { + dir: () => getStatePath('devices'), + file: (id) => `${id}.json`, + idOf: (relative) => (DEVICE_FILE_RE.test(relative) ? relative.slice(0, -'.json'.length) : null), + mode: 0o600, + }, +}; + +interface StoredDevice extends Omit { + secretHash: string; +} + +function collection() { + return getStore().collection('devices', DEVICES_COLLECTION); +} + +function hashSecret(secret: string): string { + return createHash('sha256').update(secret).digest('hex'); +} + +function secretMatches(device: StoredDevice, secret: string): boolean { + const given = Buffer.from(hashSecret(secret)); + const stored = Buffer.from(device.secretHash); + return given.length === stored.length && timingSafeEqual(given, stored); +} + +function parse(data: Buffer | null | undefined): StoredDevice | null { + if (!data) return null; + try { + return JSON.parse(data.toString('utf8')) as StoredDevice; + } catch { + return null; + } +} + +function toView(id: string, device: StoredDevice): DeviceView { + const { secretHash: _secretHash, ...rest } = device; + return { id, userKey: id.split('/')[0], ...rest }; +} + +async function readDevice(id: string): Promise { + return parse((await collection().get(id))?.data); +} + +async function readMany(ids: string[]): Promise> { + const found: Array<[string, StoredDevice]> = []; + for (let start = 0; start < ids.length; start += READ_CONCURRENCY) { + const batch = ids.slice(start, start + READ_CONCURRENCY); + const devices = await Promise.all(batch.map((id) => readDevice(id))); + devices.forEach((device, index) => { + if (device) found.push([batch[index], device]); + }); + } + return found; +} + +/** Every row of this user has a wipe waiting, which can't be dropped, and the cap is reached. */ +export class TooManyDevicesError extends Error { + constructor() { + super('Too many devices'); + this.name = 'TooManyDevicesError'; + } +} + +async function makeRoomFor(userKey: string, now: number): Promise { + const rows = await pruned(await readMany(await collection().list(`${userKey}/`)), now); + const over = rows.length - MAX_DEVICES_PER_USER + 1; + if (over <= 0) return; + const removable = rows + .filter(([, device]) => !device.wipe) + .sort(([, a], [, b]) => a.lastSeen.localeCompare(b.lastSeen)); + if (removable.length < over) throw new TooManyDevicesError(); + for (const [id] of removable.slice(0, over)) await collection().delete(id); +} + +/** Abandoned rows of everyone, however long no admin has looked. One replica, once an hour. */ +async function pruneAbandoned(now: number): Promise { + const { ok } = await getStore().counters.reserve([{ key: 'device-prune', max: 1, windowMs: PRUNE_EVERY_MS }]); + if (ok) await pruned(await readMany(await collection().list('')), now); +} + +export interface DeviceInfo { + platform: string; + appVersion: string; +} + +/** A new row for this browser profile and account. The secret goes back to the device once and is kept only as a hash. */ +export async function registerDevice(user: string, server: string, info: DeviceInfo): Promise<{ id: string; secret: string; user: string; server: string }> { + const userKey = settingsId(user, server); + await makeRoomFor(userKey, Date.now()); + await pruneAbandoned(Date.now()).catch(() => {}); + const id = `${userKey}/${randomBytes(16).toString('hex')}`; + const secret = randomBytes(32).toString('hex'); + const now = new Date().toISOString(); + const device: StoredDevice = { + user, + server, + platform: info.platform, + appVersion: info.appVersion, + firstSeen: now, + lastSeen: now, + wipe: null, + wipedAt: null, + wipeReason: null, + secretHash: hashSecret(secret), + }; + await collection().put(id, JSON.stringify(device), { ifVersion: null }); + return { id, secret, user, server }; +} + +/** Null when the row is gone or the secret doesn't fit. Both read the same to the caller. */ +export async function checkInDevice(id: string, secret: string, info: DeviceInfo, now = Date.now()): Promise { + const current = await readDevice(id); + if (!current || !secretMatches(current, secret)) return null; + const stale = now - Date.parse(current.lastSeen) >= LAST_SEEN_WRITE_MS; + if (!stale && current.platform === info.platform && current.appVersion === info.appVersion) return toView(id, current); + const { data } = await collection().update(id, (raw) => { + const device = parse(raw); + if (!device) return undefined; + return JSON.stringify({ ...device, ...info, lastSeen: new Date(now).toISOString() }); + }); + const device = parse(data); + return device ? toView(id, device) : toView(id, current); +} + +export type WipeConfirmation = { device: DeviceView; changed: boolean } | 'no_wipe_asked'; + +/** + * The device says it wiped. Null when the row is gone or the secret doesn't fit. + * Only a change is news: a device sends this again until it gets through, and + * anyone with a row could send it for ever. `admin` needs a request the row + * still has. Anyone can say they wiped themselves, but not that an admin told them. + */ +export async function confirmDeviceWipe(id: string, secret: string, reason: WipeReason, now = Date.now()): Promise { + const current = await readDevice(id); + if (!current || !secretMatches(current, secret)) return null; + const at = new Date(now).toISOString(); + let refused = false; + const { data } = await collection().update(id, (raw) => { + refused = false; + const device = parse(raw); + if (!device) return undefined; + if (device.wipedAt && !device.wipe) return undefined; + if (reason === 'admin' && !device.wipe) { + refused = true; + return undefined; + } + return JSON.stringify({ ...device, wipe: null, wipedAt: at, wipeReason: reason, lastSeen: at }); + }); + if (refused) return 'no_wipe_asked'; + const device = parse(data); + if (device) return { device: toView(id, device), changed: true }; + const after = await readDevice(id); + return after ? { device: toView(id, after), changed: false } : null; +} + +/** + * The account signed out here and took its data with it: the row has nothing + * left to look after. A wipe still waiting is answered by that, and stays on the + * record the way any confirmed wipe does. Null when the secret doesn't fit. + */ +export async function dropDevice(id: string, secret: string, now = Date.now()): Promise<{ confirmed: DeviceView | null } | null> { + const current = await readDevice(id); + if (!current) return { confirmed: null }; + if (!secretMatches(current, secret)) return null; + if (current.wipe) { + const answer = await confirmDeviceWipe(id, secret, 'user', now); + return { confirmed: answer && answer !== 'no_wipe_asked' && answer.changed ? answer.device : null }; + } + await collection().delete(id); + return { confirmed: null }; +} + +/** Null when nothing changed: no such row, a wipe already waiting, or a row that confirmed and has nothing left. */ +export async function requestDeviceWipe(id: string, by: string, now = Date.now()): Promise { + const requestedAt = new Date(now).toISOString(); + const { data } = await collection().update(id, (raw) => { + const device = parse(raw); + if (!device || device.wipedAt || device.wipe) return undefined; + return JSON.stringify({ ...device, wipe: { requestedAt, by } }); + }); + const device = parse(data); + return device ? toView(id, device) : null; +} + +/** Every device of a user that hasn't wiped already, one request each. */ +export async function requestUserWipe(userKey: string, by: string, now = Date.now()): Promise { + // No more than a user can have: nothing a stray write left behind makes this unbounded. + const ids = (await collection().list(`${userKey}/`)).slice(0, MAX_DEVICES_PER_USER); + const results = await Promise.all(ids.map((id) => requestDeviceWipe(id, by, now))); + return results.filter((device): device is DeviceView => device !== null); +} + +export async function cancelDeviceWipe(id: string): Promise { + const { data } = await collection().update(id, (raw) => { + const device = parse(raw); + if (!device?.wipe) return undefined; + return JSON.stringify({ ...device, wipe: null }); + }); + const device = parse(data); + return device ? toView(id, device) : null; +} + +export async function forgetDevice(id: string): Promise { + return collection().delete(id); +} + +export async function getDevice(id: string): Promise { + const device = await readDevice(id); + return device ? toView(id, device) : null; +} + +export function isUserKey(value: unknown): value is string { + return typeof value === 'string' && USER_KEY_RE.test(value); +} + +export interface DevicePage { + devices: DeviceView[]; + total: number; +} + +/** One page, by user then device. `query` looks through every row, so it reads them all. */ +export async function listDevices(options: { offset?: number; limit?: number; query?: string; now?: number } = {}): Promise { + const { offset = 0, limit = 100, query, now = Date.now() } = options; + const ids = await collection().list(''); + const needle = query?.trim().toLowerCase(); + if (!needle) { + const page = await readMany(ids.slice(offset, offset + limit)); + const kept = await pruned(page, now); + return { devices: kept.map(([id, device]) => toView(id, device)), total: ids.length }; + } + const everything = await pruned(await readMany(ids), now); + const matching = everything.filter(([, device]) => device.user.toLowerCase().includes(needle)); + return { + devices: matching.slice(offset, offset + limit).map(([id, device]) => toView(id, device)), + total: matching.length, + }; +} + +/** What the admin is about to see: old rows go, a row with a wipe waiting never does. */ +async function pruned(rows: Array<[string, StoredDevice]>, now: number): Promise> { + const kept: Array<[string, StoredDevice]> = []; + for (const row of rows) { + const [id, device] = row; + if (!device.wipe && now - Date.parse(device.lastSeen) > DEVICE_PRUNE_MS) await collection().delete(id); + else kept.push(row); + } + return kept; +} diff --git a/lib/devices/types.ts b/lib/devices/types.ts new file mode 100644 index 000000000..367b6bef7 --- /dev/null +++ b/lib/devices/types.ts @@ -0,0 +1,30 @@ +/** + * The device registry as both sides see it: what the instance lists for its + * admin and what a device says about its own wipe. No secrets, no store. + */ + +/** Who asked for a wipe. `rejected`: the server refused the stored login for good. */ +export const WIPE_REASONS = ['admin', 'user', 'rejected'] as const; +export type WipeReason = (typeof WIPE_REASONS)[number]; + +export interface DeviceView { + /** `/`. */ + id: string; + /** The first part of `id`: every device of this user on this server. */ + userKey: string; + user: string; + server: string; + platform: string; + appVersion: string; + firstSeen: string; + lastSeen: string; + /** Asked for and not confirmed yet. Stays until the device answers or the admin takes it back. */ + wipe: { requestedAt: string; by: string } | null; + wipedAt: string | null; + wipeReason: WipeReason | null; +} + +export const AUDIT_DEVICE_WIPE_REQUEST = 'device.wipe.request'; +export const AUDIT_DEVICE_WIPE_CANCEL = 'device.wipe.cancel'; +export const AUDIT_DEVICE_WIPE_CONFIRM = 'device.wipe.confirm'; +export const AUDIT_DEVICE_FORGET = 'device.forget'; diff --git a/lib/devices/wipe-state.ts b/lib/devices/wipe-state.ts new file mode 100644 index 000000000..2b8d9b749 --- /dev/null +++ b/lib/devices/wipe-state.ts @@ -0,0 +1,183 @@ +/** + * What a wipe has to remember in localStorage, in a store-free module the auth + * store can import. Nothing here names a subject, and what stays after a wipe + * is a count, a row id and the secret for that one row. + */ + +import { EVICTED_KEEP_MS, readEvicted } from '@/lib/offline/state'; +import type { WipeReason } from './types'; + +const PENDING_KEY = 'bulwark-wipe-pending'; +const CONFIRM_KEY = 'bulwark-wipe-confirm'; +const NOTE_KEY = 'bulwark-wipe-note'; +const REFUSED_KEY = 'bulwark-credentials-refused'; +const DATABASES_KEY = 'bulwark-wipe-databases'; + +/** The pending entry for a wipe of everything on this device. */ +export const WHOLE_DEVICE = '*'; + +/** + * A 401 after re-auth has to last this long before the login counts as refused. + * Two answers a moment apart can be one bad moment on the server. + */ +export const CREDENTIALS_REFUSED_CONFIRM_MS = 60_000; + +function readJson(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} + +function writeJson(key: string, value: unknown, empty: boolean): void { + try { + if (empty) localStorage.removeItem(key); + else localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* storage unavailable */ + } +} + +// ---- A wipe under way: finished at the next page load if this one dies ---- + +export function readPendingWipes(): Record { + return readJson>(PENDING_KEY, {}); +} + +export function markWipePending(account: string, reason: WipeReason): void { + writeJson(PENDING_KEY, { ...readPendingWipes(), [account]: reason }, false); +} + +export function clearWipePending(account: string): void { + const pending = readPendingWipes(); + delete pending[account]; + writeJson(PENDING_KEY, pending, Object.keys(pending).length === 0); +} + +// ---- Telling the instance: sent until it gets through ---- + +export interface WipeConfirmation { + id: string; + secret: string; + reason: WipeReason; + /** Null: it couldn't be counted. Not the same as none. */ + unsent: number | null; + /** Whose wipe this says is done: not sent until it is. Stays on this device. */ + account?: string; + /** Not a wipe but a sign-out, whose row goes too. */ + drop?: boolean; +} + +export function readWipeConfirmations(): WipeConfirmation[] { + const list = readJson(CONFIRM_KEY, []); + return Array.isArray(list) ? list : []; +} + +export function queueWipeConfirmation(entry: WipeConfirmation): void { + writeJson(CONFIRM_KEY, [...readWipeConfirmations().filter((other) => other.id !== entry.id), entry], false); +} + +export function dropWipeConfirmation(id: string): void { + const rest = readWipeConfirmations().filter((entry) => entry.id !== id); + writeJson(CONFIRM_KEY, rest, rest.length === 0); +} + +// ---- The note at the next sign-in: a count, never subjects ---- + +export interface WipeNote { + unsent: number; + /** Some of it couldn't be counted, so there may have been more than `unsent`. */ + unknown?: true; +} + +export function addWipeNote(unsent: number | null): void { + const before = readJson(NOTE_KEY, null); + const note: WipeNote = { unsent: (before?.unsent ?? 0) + (unsent ?? 0) }; + if (before?.unknown || unsent === null) note.unknown = true; + writeJson(NOTE_KEY, note, false); +} + +/** Once: whoever shows it takes it. Null when no wipe left a word. */ +export function consumeWipeNote(): WipeNote | null { + const note = readJson(NOTE_KEY, null); + writeJson(NOTE_KEY, null, true); + if (!note || typeof note.unsent !== 'number') return null; + return note.unknown ? { unsent: note.unsent, unknown: true } : { unsent: note.unsent }; +} + +// ---- A refused login: what waits for the new password. A count, never subjects ---- + +export interface RefusedLogin { + at: number; + /** Unsent messages waiting. Null: not counted yet, or the store couldn't say. */ + unsent: number | null; + /** Not one bad answer at a start: what a deletion the organisation asked for needs. */ + certain: boolean; +} + +export function readRefusedLogins(): Record { + return readJson>(REFUSED_KEY, {}); +} + +const refusedListeners = new Set<() => void>(); + +/** For the sign-in page: the count comes after the page does. */ +export function onRefusedLoginsChanged(listener: () => void): () => void { + refusedListeners.add(listener); + return () => refusedListeners.delete(listener); +} + +function writeRefused(refused: Record): void { + writeJson(REFUSED_KEY, refused, Object.keys(refused).length === 0); + for (const listener of [...refusedListeners]) listener(); +} + +export function markLoginRefused(account: string, certain = true, now = Date.now()): void { + const before = readRefusedLogins(); + writeRefused({ ...before, [account]: { at: before[account]?.at ?? now, unsent: null, certain: certain || before[account]?.certain === true } }); +} + +export function setRefusedCount(account: string, unsent: number | null): void { + const before = readRefusedLogins(); + if (before[account]) writeRefused({ ...before, [account]: { ...before[account], unsent } }); +} + +export function dropRefusedLogin(account: string): void { + const refused = readRefusedLogins(); + if (!(account in refused)) return; + delete refused[account]; + writeRefused(refused); +} + +export interface RefusedWait { + unsent: number | null; + /** When the data goes if nobody signs in. */ + until: number; +} + +/** What the sign-in page says. Numbers only: whoever reads it may not be the person it's about. */ +export function refusedWaits(): RefusedWait[] { + const evicted = readEvicted(); + return Object.entries(readRefusedLogins()) + .filter(([account, wait]) => account in evicted && wait.unsent !== 0) + .map(([account, wait]) => ({ unsent: wait.unsent, until: evicted[account] + EVICTED_KEEP_MS })); +} + +// ---- Plugin databases a running frame kept open: tried again at the next load ---- + +export function readBlockedDatabases(): Record { + return readJson>(DATABASES_KEY, {}); +} + +export function writeBlockedDatabases(blocked: Record): void { + writeJson(DATABASES_KEY, blocked, Object.keys(blocked).length === 0); +} + +/** Whether anything is left over from an earlier wipe, so a start must look. */ +export function wipeWorkWaiting(): boolean { + return Object.keys(readPendingWipes()).length > 0 + || readWipeConfirmations().length > 0 + || Object.keys(readBlockedDatabases()).length > 0; +} diff --git a/lib/jmap/__tests__/credentials-refused.test.ts b/lib/jmap/__tests__/credentials-refused.test.ts new file mode 100644 index 000000000..928915ed7 --- /dev/null +++ b/lib/jmap/__tests__/credentials-refused.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '../client'; + +// A 401 after a fresh try at signing in is the one signal a password login has +// that its credentials are over. So what else looks like one must not count. + +const SERVER = 'https://mail.example.test'; +const problem = () => new Response(JSON.stringify({ type: 'about:blank', status: 401, title: 'Unauthorized', detail: 'You have to authenticate first.' }), { status: 401, headers: { 'Content-Type': 'application/problem+json' } }); +const html401 = () => new Response('Sign in to the proxy', { status: 401, headers: { 'Content-Type': 'text/html' } }); +const session = () => new Response(JSON.stringify({ + apiUrl: `${SERVER}/jmap`, downloadUrl: `${SERVER}/d`, uploadUrl: `${SERVER}/u`, eventSourceUrl: '', username: 'alice', + capabilities: { 'urn:ietf:params:jmap:core': {} }, accounts: { a: { name: 'alice', isPersonal: true, accountCapabilities: {} } }, primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, +}), { status: 200, headers: { 'Content-Type': 'application/json' } }); +const echo = () => new Response(JSON.stringify({ methodResponses: [['Core/echo', { ping: 'pong' }, '0']], sessionState: 's' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + +let answers: { api: () => Response; session: () => Response }; + +function stubServer(): void { + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => (String(input).includes('/.well-known/jmap') ? answers.session() : answers.api()))); +} + +async function connectedBasic(): Promise<{ client: JMAPClient; refused: number[] }> { + answers = { session, api: echo }; + const client = new JMAPClient(SERVER, 'alice', 'secret'); + await client.connect(); + const refused: number[] = []; + client.onCredentialsRefused((ms) => refused.push(ms)); + return { client, refused }; +} + +beforeEach(() => { + vi.useRealTimers(); + stubServer(); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('a password login', () => { + it('counts when the session URL refuses too', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: problem, session: problem }; + await client.ping().catch(() => {}); + expect(refused).toHaveLength(1); + client.disconnect(); + }); + + it('counts when the session answers and the retry is refused', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: problem, session }; + await client.ping().catch(() => {}); + expect(refused).toHaveLength(1); + client.disconnect(); + }); + + it('doesn\'t count when the session URL gives a 500: that says nothing about the password', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: problem, session: () => new Response('oops', { status: 500 }) }; + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('doesn\'t count when the network breaks on the way', async () => { + const { client, refused } = await connectedBasic(); + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => { + if (String(input).includes('/.well-known/jmap')) throw new TypeError('Failed to fetch'); + return problem(); + })); + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('doesn\'t count a proxy\'s page', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: html401, session: html401 }; + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('doesn\'t count a plain 500, or a 403', async () => { + const { client, refused } = await connectedBasic(); + answers = { api: () => new Response('nope', { status: 500 }), session }; + await client.ping().catch(() => {}); + answers = { api: () => new Response(JSON.stringify({ status: 403 }), { status: 403 }), session }; + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('counts at connect, so a restore can tell', async () => { + answers = { api: echo, session: problem }; + const client = new JMAPClient(SERVER, 'alice', 'secret'); + const refused: number[] = []; + client.onCredentialsRefused((ms) => refused.push(ms)); + await expect(client.connect()).rejects.toThrow('Invalid username or password'); + expect(refused).toHaveLength(1); + }); + + it('never counts with a one-time code: those go stale by themselves', async () => { + const { client, refused } = await connectedBasic(); + client.enableTotpReauth('secret', async () => null); + answers = { api: problem, session: problem }; + await client.ping().catch(() => {}); + expect(refused).toEqual([]); + client.disconnect(); + }); + + it('measures from the first refusal, and a good answer starts it over', async () => { + const { client, refused } = await connectedBasic(); + const now = vi.spyOn(Date, 'now'); + now.mockReturnValue(1_000_000); + answers = { api: problem, session: problem }; + await client.ping().catch(() => {}); + now.mockReturnValue(1_090_000); + await client.ping().catch(() => {}); + expect(refused).toEqual([0, 90_000]); + + answers = { api: echo, session }; + now.mockReturnValue(1_100_000); + await client.ping(); + answers = { api: problem, session: problem }; + now.mockReturnValue(1_200_000); + await client.ping().catch(() => {}); + expect(refused).toEqual([0, 90_000, 0]); + client.disconnect(); + }); +}); + +describe('a token login', () => { + function bearer(renewed: string | null): { client: JMAPClient; refused: number[]; renew: ReturnType } { + const renew = vi.fn(async () => renewed); + const client = JMAPClient.withBearer(SERVER, 'old', 'alice', renew); + const refused: number[] = []; + client.onCredentialsRefused((ms) => refused.push(ms)); + return { client, refused, renew }; + } + + it('counts when a token issued just now is refused', async () => { + answers = { api: problem, session: problem }; + const { client, refused, renew } = bearer('new'); + await expect(client.connect()).rejects.toThrow(); + expect(renew).toHaveBeenCalled(); + expect(refused).toHaveLength(1); + }); + + it('doesn\'t count when no new token came: the renewal has its own rules', async () => { + answers = { api: problem, session: problem }; + const { client, refused } = bearer(null); + await expect(client.connect()).rejects.toThrow(); + expect(refused).toEqual([]); + }); + + it('counts for a pasted token, which nothing can renew', async () => { + answers = { api: problem, session: problem }; + const client = JMAPClient.withBearer(SERVER, 'pasted', 'alice'); + const refused: number[] = []; + client.onCredentialsRefused((ms) => refused.push(ms)); + await expect(client.connect()).rejects.toThrow(); + expect(refused).toHaveLength(1); + }); +}); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 487b060e7..766631e37 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -171,6 +171,12 @@ export interface IJMAPClient { hasSSEStream(): boolean; onConnectionChange(callback: (connected: boolean) => void): void; onRateLimit(callback: (rateLimited: boolean, retryAfterMs: number) => void): void; + /** + * The mail server itself answered 401 after a fresh try at signing in, with + * `refusedForMs` since it first did with no good answer in between. Not called + * for an outage, a proxy's page or a token renewal that failed. + */ + onCredentialsRefused?(callback: (refusedForMs: number) => void): void; isRateLimited(): boolean; getRateLimitRemainingMs(): number; onStateChange(callback: (change: StateChange) => void): void; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 983431811..35b9c811f 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1621,6 +1621,9 @@ export class JMAPClient implements IJMAPClient { private lastStates: AccountStates = {}; private reconnecting = false; private connectionChangeCallback: ((connected: boolean) => void) | null = null; + private credentialsRefusedCallback: ((refusedForMs: number) => void) | null = null; + /** When the server first refused us with no good answer since; 0 when it hasn't. */ + private refusedSince = 0; private rateLimitedUntil: number = 0; private rateLimitCallback: ((rateLimited: boolean, retryAfterMs: number) => void) | null = null; private rateLimitTimeout: NodeJS.Timeout | null = null; @@ -1908,6 +1911,7 @@ export class JMAPClient implements IJMAPClient { throw new RateLimitError(retryAfterMs); } + let reauthRefused = false; if (response.status === 401) { if (this.authMode === 'bearer' && this.onTokenRefresh) { const newToken = await this.onTokenRefresh(); @@ -1915,7 +1919,13 @@ export class JMAPClient implements IJMAPClient { this.updateAccessToken(newToken); const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; response = await this.timedFetch(url, init, retryHeaders, timeoutMs); + // A token issued just now was refused. No new token says nothing: + // the renewal has its own rules for that. + reauthRefused = true; } + } else if (this.authMode === 'bearer') { + // A pasted token: there's nothing to renew it with. + reauthRefused = true; } else if (this.authMode === 'basic' && !this.reconnecting && url !== this.discoveryUrl()) { // JMAP session may have expired - re-establish and retry once this.reconnecting = true; @@ -1924,7 +1934,11 @@ export class JMAPClient implements IJMAPClient { this.connectionChangeCallback?.(true); const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; response = await this.timedFetch(url, init, retryHeaders, timeoutMs); - } catch { + reauthRefused = true; + } catch (refreshError) { + // A 500 or a broken connection on the way says nothing against the + // password. Only the session URL's own 401 does. + reauthRefused = refreshError instanceof Error && refreshError.message === 'Session refresh failed: 401'; // Session refresh failed - if TOTP was used, try re-auth with fresh TOTP if (this.onTotpRequired && this.basePassword) { try { @@ -1946,10 +1960,34 @@ export class JMAPClient implements IJMAPClient { } } - if (response.status !== 401) this.authAcceptedAt = Date.now(); + if (response.status !== 401) { + this.authAcceptedAt = Date.now(); + this.refusedSince = 0; + } else if (reauthRefused && !response.redirected) { + await this.noteCredentialsRefused(response); + } return response; } + onCredentialsRefused(callback: (refusedForMs: number) => void): void { + this.credentialsRefusedCallback = callback; + } + + /** + * Only the mail server's own refusal counts: JMAP errors are problem documents + * (RFC 8620 section 3.6.1). A proxy or a login page in front of the server + * answers with something else, and says nothing about these credentials. + * A password with a one-time code goes stale by itself, so it never counts. + */ + private async noteCredentialsRefused(response: Response): Promise { + if (this.onTotpRequired || this.basePassword) return; + const body = await response.clone().json().catch(() => null) as { status?: unknown } | null; + if (body?.status !== 401) return; + const now = Date.now(); + if (!this.refusedSince) this.refusedSince = now; + this.credentialsRefusedCallback?.(now - this.refusedSince); + } + /** * Fetch the JMAP session, transparently handling servers that redirect * /.well-known/jmap to a canonical session URL (e.g. Stalwart → /jmap/session). @@ -2016,6 +2054,8 @@ export class JMAPClient implements IJMAPClient { if (!sessionResponse.ok) { if (sessionResponse.status === 401) { + // A token's refusal was counted on the way here, if it counts at all. + if (this.authMode === 'basic' && !sessionResponse.redirected) await this.noteCredentialsRefused(sessionResponse); throw new Error(this.authMode === 'bearer' ? 'Authentication failed - token may be expired' : 'Invalid username or password'); diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts new file mode 100644 index 000000000..8118687a6 --- /dev/null +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -0,0 +1,1148 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// What a wipe removes and what makes one happen. Real auth store, real offline +// mode, the store's real engine, real IndexedDB (fake-indexeddb): only the +// network is a stub, and the OPFS pool, which jsdom doesn't have. + +vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })) })); +const purge = vi.hoisted(() => ({ files: 0, fail: false, during: null as null | (() => void) })); +vi.mock('@/lib/store/client/purge', async () => { + const keys = await vi.importActual('@/lib/store/client/keys'); + return { + purgeStoreFiles: async () => { + purge.during?.(); + if (purge.fail) throw new Error('The files are held'); + purge.files += 1; + keys.deleteKeyDatabase(); + }, + deleteStorePool: async () => {}, + }; +}); + +// A store and a full sign-out in each test: slow on a machine doing other work. +vi.setConfig({ testTimeout: 60_000 }); + +const ALICE = 'alice@mail.example.test'; +const BOB = 'bob@mail.example.test'; +const ROW = `${'a'.repeat(64)}/${'b'.repeat(32)}`; +const SECRET = 'c'.repeat(64); +const BOB_ROW = `${'d'.repeat(64)}/${'e'.repeat(32)}`; + +let wipe: typeof import('../device-wipe'); +let useAuthStore: typeof import('@/stores/auth-store').useAuthStore; +let useAccountStore: typeof import('@/stores/account-store').useAccountStore; +let state: typeof import('../state'); +let keys: typeof import('@/lib/store/client/keys'); +let wipeState: typeof import('@/lib/devices/wipe-state'); +let rejected: typeof import('../credentials-rejected'); +let registry: typeof import('../wipe-registry'); +let queue: typeof import('../queue'); +let store: import('@/lib/store/__tests__/helpers/in-process-store').InProcessStore; + +type Respond = (url: string, init?: RequestInit) => Response | Promise; +let respond: Respond; +let sent: Array<{ url: string; body: Record; headers: Record }>; + +const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +const noWipe = () => json(200, { wipe: null }); +/** What the route gives a new row. */ +const newRow = (id = ROW) => json(200, { id, secret: SECRET, user: 'alice', server: 'https://mail.example.test' }); +const wipeNow = () => json(200, { wipe: { requestedAt: '2026-11-03T00:00:00.000Z' } }); + +function entry(id: string, cookieSlot: number) { + return { + id, label: id, serverUrl: 'https://mail.example.test', username: id.split('@mail')[0], authMode: 'basic', rememberMe: true, + displayName: id, email: id, lastLoginAt: 1, isConnected: true, hasError: false, isDefault: cookieSlot === 0, cookieSlot, avatarColor: '#000', + }; +} + +function openDb(name: string): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, 1); + request.onupgradeneeded = () => { request.result.createObjectStore('s'); }; + request.onsuccess = () => { request.result.close(); resolve(); }; + request.onerror = () => reject(request.error); + }); +} + +async function databases(): Promise { + return (await indexedDB.databases()).map((database) => database.name ?? '').sort(); +} + +async function freshIndexedDb(): Promise { + for (const { name } of await indexedDB.databases()) { + if (name) await new Promise((resolve) => { const request = indexedDB.deleteDatabase(name); request.onsuccess = request.onerror = () => resolve(null); }); + } +} + +beforeEach(async () => { + vi.resetModules(); + purge.files = 0; + purge.fail = false; + purge.during = null; + localStorage.clear(); + sessionStorage.clear(); + await freshIndexedDb(); + sent = []; + respond = () => noWipe(); + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/devices')) sent.push({ url, body: init?.body ? JSON.parse(String(init.body)) : {}, headers: (init?.headers ?? {}) as Record }); + return respond(url, init); + })); + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + + ({ useAuthStore } = await import('@/stores/auth-store')); + ({ useAccountStore } = await import('@/stores/account-store')); + state = await import('../state'); + keys = await import('@/lib/store/client/keys'); + wipeState = await import('@/lib/devices/wipe-state'); + rejected = await import('../credentials-rejected'); + registry = await import('../wipe-registry'); + queue = await import('../queue'); + wipe = await import('../device-wipe'); + const browserNavigation = await import('@/lib/browser-navigation'); + vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {}); + const { InProcessStore } = await import('@/lib/store/__tests__/helpers/in-process-store'); + const { setStoreBackend } = await import('@/lib/store/client/api'); + store = new InProcessStore(); + setStoreBackend(store); + + useAccountStore.setState({ accounts: [entry(ALICE, 0)], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ isAuthenticated: true, activeAccountId: ALICE, authMode: 'basic', serverUrl: 'https://mail.example.test', username: 'alice' }); + state.writeEnabled([ALICE]); + await keys.openAccountKey(ALICE); + await keys.putDeviceRecord({ account: ALICE, id: ROW, secret: SECRET, createdAt: 1 }); +}, 60_000); + +/** The wipe goes on behind the start's budget, and takes its time under load. */ +async function waitForNoAccounts(): Promise { + await vi.waitFor(() => expect(useAccountStore.getState().accounts).toEqual([]), { timeout: 20_000 }); +} + +function expectUntouched(): void { + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); + expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([ALICE]); + expect(wipeState.readPendingWipes()).toEqual({}); +} + +describe('wipe on the next connect', () => { + beforeEach(() => { + respond = (url) => (url.endsWith('/api/devices/wiped') ? json(200, { ok: true }) : wipeNow()); + }); + + it('removes the store and every key, signs out, and tells the instance', async () => { + await queue.queueSend(ALICE, { subject: 'Secret plans', to: [] }, { kind: 'email', args: [] }); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + + expect(purge.files).toBeGreaterThan(0); + expect(state.useOfflineStore.getState().enabled).toEqual([]); + expect(await databases()).not.toContain('bulwark-store'); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + + const report = sent.find((call) => call.url.endsWith('/api/devices/wiped')); + expect(report?.body).toMatchObject({ id: ROW, secret: SECRET, reason: 'admin', unsent: 1 }); + expect(wipeState.readWipeConfirmations()).toEqual([]); + expect(wipeState.readPendingWipes()).toEqual({}); + }); + + it('notes the count for the next sign-in, and no subject anywhere', async () => { + await queue.queueSend(ALICE, { subject: 'Secret plans', to: [] }, { kind: 'email', args: [] }); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(JSON.stringify({ ...localStorage })).not.toContain('Secret plans'); + expect(JSON.stringify({ ...sessionStorage })).not.toContain('Secret plans'); + expect(wipeState.consumeWipeNote()).toEqual({ unsent: 1 }); + expect(wipeState.consumeWipeNote()).toBeNull(); + }); + + it('leaves a note with no count when nothing was waiting', async () => { + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(wipeState.consumeWipeNote()).toEqual({ unsent: 0 }); + }); + + it('deletes S/MIME and OpenPGP keys by name, plugin data and handoffs, keeping the plugins themselves', async () => { + for (const name of ['smime-plugin-store', 'bulwark-openpgp', 'made-by-some-plugin']) await openDb(name); + const { fileStorage } = await import('@/lib/plugin-storage'); + const clearFiles = vi.spyOn(fileStorage, 'clearFiles').mockResolvedValue(); + const { pluginStoragePrefix } = await import('@/lib/plugin-sandbox/storage-scope'); + localStorage.setItem(`${pluginStoragePrefix('notes', ALICE)}note`, 'mine'); + localStorage.setItem('notification-center', '[{"title":"From Eve"}]'); + const { usePluginStore } = await import('@/stores/plugin-store'); + usePluginStore.setState({ plugins: [{ id: 'managed', managed: true, settings: { apiKey: 'k' } }] as never }); + const handoffs = await import('@/lib/launch-handoff'); + const clearHandoffs = vi.spyOn(handoffs, 'clearHandoffs'); + + await wipe.runDeviceGate(); + await waitForNoAccounts(); + + const left = await databases(); + for (const name of ['smime-plugin-store', 'bulwark-openpgp', 'made-by-some-plugin']) expect(left).not.toContain(name); + expect(localStorage.getItem(`${pluginStoragePrefix('notes', ALICE)}note`)).toBeNull(); + expect(localStorage.getItem('notification-center') ?? '[]').toBe('[]'); + expect(usePluginStore.getState().plugins[0].settings).toEqual({}); + expect(clearFiles).toHaveBeenCalled(); + expect(clearHandoffs).toHaveBeenCalled(); + }); + + it('runs what features registered, edit-in-place\'s files for one, with the account', async () => { + const cleaned: Array = []; + registry.onDeviceWipe(async (account) => { cleaned.push(account); }); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(cleaned).toEqual([ALICE]); + }); + + it('a feature that fails or hangs doesn\'t stop the wipe', async () => { + registry.onDeviceWipe(() => { throw new Error('no'); }); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(purge.files).toBeGreaterThan(0); + }); + + it('only takes the account it was told, and keeps the other\'s store and row', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.openAccountKey(BOB); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + respond = (url, init) => { + if (url.endsWith('/api/devices/wiped')) return json(200, { ok: true }); + return JSON.parse(String(init?.body)).id === ROW ? wipeNow() : noWipe(); + }; + + await wipe.runDeviceGate(); + await vi.waitFor(() => expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([BOB])); + + expect(state.useOfflineStore.getState().enabled).toEqual([BOB]); + expect(store.removed).toContain(ALICE); + expect(store.removed).not.toContain(BOB); + expect(await keys.getDeviceRecord(ALICE)).toBeNull(); + expect((await keys.getDeviceRecord(BOB))?.id).toBe(BOB_ROW); + }); + + it('after the last of several accounts goes, the rest keep their row', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + // Bob keeps nothing here but has a row: the desktop app, say. + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + respond = (url, init) => { + if (url.endsWith('/api/devices/wiped')) return json(200, { ok: true }); + return JSON.parse(String(init?.body)).id === ROW ? wipeNow() : noWipe(); + }; + await wipe.runDeviceGate(); + await vi.waitFor(() => expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([BOB])); + expect((await keys.getDeviceRecord(BOB))?.id).toBe(BOB_ROW); + }); + + it('reaches an account dropped earlier, whose data waits and which has no cookies', async () => { + useAccountStore.setState({ accounts: [] } as never); + useAuthStore.setState({ isAuthenticated: false, activeAccountId: null }); + state.markEvicted(ALICE); + await wipe.runDeviceGate(); + await vi.waitFor(() => expect(state.useOfflineStore.getState().enabled).toEqual([])); + expect(purge.files).toBeGreaterThan(0); + expect(state.isEvicted(ALICE)).toBe(false); + }); +}); + +describe('the report', () => { + it('goes out again at the next contact when it failed, with no account and no cookies', async () => { + let reportWorks = false; + respond = (url) => { + if (url.endsWith('/api/devices/wiped')) return reportWorks ? json(200, { ok: true }) : json(503, {}); + return wipeNow(); + }; + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(wipeState.readWipeConfirmations().map((entry) => entry.id)).toEqual([ROW]); + + // A new page load, the sign-in page: everything else about the account is gone. + reportWorks = true; + await wipe.checkInDue(true); + await vi.waitFor(() => expect(wipeState.readWipeConfirmations()).toEqual([])); + const reports = sent.filter((call) => call.url.endsWith('/api/devices/wiped')); + expect(reports.length).toBeGreaterThanOrEqual(2); + expect(reports.at(-1)?.body).toMatchObject({ id: ROW, secret: SECRET }); + }); +}); + +describe('what never wipes', () => { + it.each([ + ['a 500', () => json(500, { error: 'Internal server error' })], + ['a 503 page', () => new Response('Bad gateway', { status: 503 })], + ['an older instance with no such route', () => new Response('Not found', { status: 404 })], + ['a 401', () => json(401, { error: 'Not authenticated' })], + ['a 200 that says nothing', () => json(200, {})], + ])('%s', async (_name, answer) => { + respond = answer; + await wipe.runDeviceGate(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expectUntouched(); + }); + + it('no network, and the row keeps waiting at the instance: nothing was reported', async () => { + respond = () => { throw new TypeError('Failed to fetch'); }; + await wipe.runDeviceGate(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expectUntouched(); + expect(sent.filter((call) => call.url.endsWith('/api/devices/wiped'))).toEqual([]); + }); + + it('a browser that says it\'s offline doesn\'t even ask', async () => { + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); + await wipe.runDeviceGate(); + vi.restoreAllMocks(); + expect(sent).toEqual([]); + expectUntouched(); + }); + + it('a forgotten row: this device registers anew and keeps its data', async () => { + respond = (_url, init) => { + const { id } = JSON.parse(String(init?.body)) as { id?: string }; + return id ? json(404, { error: 'unknown_device' }) : newRow(BOB_ROW); + }; + await wipe.runDeviceGate(); + expectUntouched(); + expect(await keys.getDeviceRecord(ALICE)).toBeNull(); + await wipe.checkInDue(true); + await vi.waitFor(async () => expect((await keys.getDeviceRecord(ALICE))?.id).toBe(BOB_ROW)); + }); +}); + +describe('a start', () => { + it('waits for the answer, at most for its budget, and a late yes still wipes', async () => { + let release: (response: Response) => void = () => {}; + respond = (url) => { + if (url.endsWith('/api/devices/wiped')) return json(200, { ok: true }); + return new Promise((resolve) => { release = resolve; }); + }; + const started = Date.now(); + await wipe.runDeviceGate(); + const waited = Date.now() - started; + expect(waited).toBeGreaterThanOrEqual(wipe.BOOT_CHECK_IN_BUDGET_MS - 50); + expect(waited).toBeLessThan(wipe.BOOT_CHECK_IN_BUDGET_MS + 1500); + expectUntouched(); + + release(wipeNow()); + await waitForNoAccounts(); + expect(purge.files).toBeGreaterThan(0); + }); + + it('finishes a wipe a closed window cut short, with no network and before anything else', async () => { + wipeState.markWipePending(ALICE, 'admin'); + respond = () => { throw new TypeError('Failed to fetch'); }; + await wipe.runDeviceGate(); + expect(purge.files).toBeGreaterThan(0); + expect(useAccountStore.getState().accounts).toEqual([]); + expect(wipeState.readPendingWipes()).toEqual({}); + }); +}); + +describe('registering', () => { + beforeEach(async () => { + await keys.deleteDeviceRecord(ALICE); + }); + + it('a browser with offline on registers, once, naming its cookie slot', async () => { + respond = () => newRow(); + await Promise.all([wipe.checkInDue(true), wipe.checkInDue(true)]); + expect(sent).toHaveLength(1); + expect(sent[0].headers['X-JMAP-Cookie-Slot']).toBe('0'); + expect(await keys.getDeviceRecord(ALICE)).toMatchObject({ id: ROW, secret: SECRET }); + }); + + it('a browser with offline off doesn\'t', async () => { + state.writeEnabled([]); + await wipe.checkInDue(true); + expect(sent).toEqual([]); + expect(await databases()).toContain('bulwark-store'); // From this test's own setup. + }); + + it('a desktop app on an instance does, with offline off too', async () => { + state.writeEnabled([]); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; + respond = () => newRow(); + await wipe.checkInDue(true); + expect(sent).toHaveLength(1); + }); + + it('the desktop app on its own has no instance to ask', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'standalone' }; + await wipe.checkInDue(true); + expect(sent).toEqual([]); + }); + + it('a cleared key database means a new row', async () => { + respond = () => newRow(); + await wipe.checkInDue(true); + keys.deleteKeyDatabase(); + respond = () => newRow(BOB_ROW); + await wipe.checkInDue(true); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(BOB_ROW); + }); + + it('a device id doesn\'t survive a wipe: the next sign-in is a new row', async () => { + await keys.putDeviceRecord({ account: ALICE, id: ROW, secret: SECRET, createdAt: 1 }); + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(await keys.listDeviceRecords()).toEqual([]); + }); + + it.each([ + ['someone else\'s row', { user: 'mallory', server: 'https://mail.example.test' }], + ['another server\'s row', { user: 'alice', server: 'https://other.example.test' }], + ])('drops %s: no record here, and the row goes back', async (_name, owner) => { + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : json(200, { id: BOB_ROW, secret: SECRET, ...owner })); + await wipe.checkInDue(true); + expect(await keys.getDeviceRecord(ALICE)).toBeNull(); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ id: BOB_ROW, secret: SECRET, drop: true })); + }); + + it.each([ + ['the same name in other case', { user: 'ALICE', server: 'https://mail.example.test/' }], + ['the whole address for a bare name', { user: 'alice@mail.example.test', server: 'https://mail.example.test' }], + ])('takes %s as the same owner', async (_name, owner) => { + respond = () => json(200, { id: ROW, secret: SECRET, ...owner }); + await wipe.checkInDue(true); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW); + }); + + it('a row that was wiped already: the record goes, nothing else does, and it registers anew', async () => { + await keys.putDeviceRecord({ account: ALICE, id: ROW, secret: SECRET, createdAt: 1 }); + respond = (_url, init) => (JSON.parse(String(init?.body)).id ? json(200, { wipe: null, wiped: true }) : newRow(BOB_ROW)); + await wipe.checkInDue(true); + expect(await keys.getDeviceRecord(ALICE)).toBeNull(); + expectUntouched(); + await wipe.checkInDue(true); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(BOB_ROW); + expect(wipeState.consumeWipeNote()).toBeNull(); + }); + + it('isn\'t tried again every minute when it can\'t', async () => { + respond = () => json(401, {}); + const now = Date.now(); + await wipe.checkInDue(false, now); + await wipe.checkInDue(false, now + 60_000); + expect(sent).toHaveLength(1); + await wipe.checkInDue(false, now + 16 * 60_000); + expect(sent).toHaveLength(2); + }); +}); + +describe('check-ins while running', () => { + it('come at most once an hour for a row', async () => { + const now = Date.now(); + await wipe.checkInDue(false, now); + await wipe.checkInDue(false, now + 60_000); + expect(sent).toHaveLength(1); + await wipe.checkInDue(false, now + 61 * 60_000); + expect(sent).toHaveLength(2); + }); +}); + +/** What the auth store does around the hook. */ +function refuse(account = ALICE, certain = true): void { + wipeState.markLoginRefused(account, certain); + rejected.noteCredentialsRejected(account, certain); + useAccountStore.getState().removeAccount(account, 'evicted'); +} + +async function policyAsksForDeletion(): Promise { + const { usePolicyStore } = await import('@/stores/policy-store'); + const policy = { organisation: { devices: { wipeOnRefusedLogin: true } } }; + usePolicyStore.setState({ policy, rawPolicy: policy, subject: null, loaded: true, source: 'server', partial: false, resolveFor: () => policy } as never); +} + +const DAY = 24 * 60 * 60 * 1000; + +describe('a refused login (lib/offline/credentials-rejected.ts)', () => { + it('hides the data and deletes nothing: everyone\'s keys, plugin settings and the store stay', async () => { + await openDb('smime-plugin-store'); + await openDb('bulwark-openpgp'); + const { usePluginStore } = await import('@/stores/plugin-store'); + usePluginStore.setState({ plugins: [{ id: 'managed', settings: { apiKey: 'k' } }] as never }); + wipe.registerDeviceWipe(); + refuse(); + await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(0)); + + expect(state.isEvicted(ALICE)).toBe(true); + expect(state.visibleAccounts()).toEqual([]); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(await databases()).toEqual(expect.arrayContaining(['smime-plugin-store', 'bulwark-openpgp'])); + expect(usePluginStore.getState().plugins[0].settings).toEqual({ apiKey: 'k' }); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + expect(wipeState.readPendingWipes()).toEqual({}); + }); + + it('two weeks later a sign-in with the new password finds everything, unsent mail included', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + wipe.registerDeviceWipe(); + const at = Date.now(); + refuse(); + await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(1)); + + const offline = await import('../offline-mode'); + const later = at + 14 * DAY; + await offline.sweepAccountsGone(later); + await wipe.sweepRefusedLogins(); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + + // What the sign-in page says, in numbers only. + const [wait] = wipeState.refusedWaits(); + expect(wait).toEqual({ unsent: 1, until: expect.any(Number) }); + expect(wait.until).toBeGreaterThanOrEqual(at + state.EVICTED_KEEP_MS); + expect(wait.until).toBeLessThanOrEqual(at + state.EVICTED_KEEP_MS + 60_000); + expect(JSON.stringify({ ...localStorage })).not.toContain('"Unsent"'); + + useAccountStore.getState().addAccount(entry(ALICE, 0) as never); + expect(state.isEvicted(ALICE)).toBe(false); + expect(state.visibleAccounts()).toEqual([ALICE]); + expect((await queue.readQueue(ALICE)).outbox.map((row) => row.summary.subject)).toEqual(['Unsent']); + // Signed in: the page says nothing from here on, even before the next look. + expect(wipeState.refusedWaits()).toEqual([]); + + await wipe.sweepRefusedLogins(); + expect(wipeState.readRefusedLogins()).toEqual({}); + expect(wipeState.refusedWaits()).toEqual([]); + }); + + it('with no sign-in for 30 days it goes, and names nothing to whoever is here', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + wipe.registerDeviceWipe(); + const at = Date.now(); + refuse(); + await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(1)); + const offline = await import('../offline-mode'); + await offline.sweepAccountsGone(at + 29 * DAY); + expect(purge.files).toBe(0); + await offline.sweepAccountsGone(at + 31 * DAY); + expect(purge.files).toBeGreaterThan(0); + await wipe.sweepRefusedLogins(); + expect(wipeState.refusedWaits()).toEqual([]); + const lost = await import('../lost'); + expect(lost.consumeLost()).toEqual([]); + }); + + it('a count it can\'t get is unknown, not none, and is tried again at the next look', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + store.failArea = () => true; + wipe.registerDeviceWipe(); + refuse(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(wipeState.refusedWaits()[0].unsent).toBeNull(); + store.failArea = null; + await wipe.sweepRefusedLogins(); + expect(wipeState.refusedWaits()[0].unsent).toBe(1); + }); + + it('keeps no wait for an account with nothing kept here', async () => { + state.writeEnabled([]); + wipe.registerDeviceWipe(); + useAccountStore.getState().removeAccount(ALICE, 'evicted'); + rejected.noteCredentialsRejected(ALICE, true); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(wipeState.refusedWaits()).toEqual([]); + expect(purge.files).toBe(0); + }); + + describe('with the organisation asking for deletion at once', () => { + beforeEach(async () => { + await policyAsksForDeletion(); + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + }); + + it('takes that account\'s data and nothing shared, and tells the instance why', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.openAccountKey(BOB); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + await openDb('smime-plugin-store'); + await openDb('bulwark-openpgp'); + const { fileStorage } = await import('@/lib/plugin-storage'); + const clearFiles = vi.spyOn(fileStorage, 'clearFiles').mockResolvedValue(); + const handoffs = await import('@/lib/launch-handoff'); + const clearHandoffs = vi.spyOn(handoffs, 'clearHandoffs'); + const { pluginStoragePrefix } = await import('@/lib/plugin-sandbox/storage-scope'); + localStorage.setItem(`${pluginStoragePrefix('notes', ALICE)}note`, 'hers'); + localStorage.setItem(`${pluginStoragePrefix('notes', BOB)}note`, 'his'); + localStorage.setItem('notification-center', '[{"title":"From Eve"}]'); + localStorage.setItem('template-storage', '{"templates":[]}'); + const { usePluginStore } = await import('@/stores/plugin-store'); + usePluginStore.setState({ plugins: [{ id: 'managed', settings: { apiKey: 'k' } }] as never }); + + wipe.registerDeviceWipe(); + refuse(); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'rejected', id: ROW })); + await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({})); + + expect(store.removed).toEqual([ALICE]); + expect(await databases()).toEqual(expect.arrayContaining(['smime-plugin-store', 'bulwark-openpgp'])); + expect(usePluginStore.getState().plugins[0].settings).toEqual({ apiKey: 'k' }); + expect(localStorage.getItem(`${pluginStoragePrefix('notes', ALICE)}note`)).toBeNull(); + expect(localStorage.getItem(`${pluginStoragePrefix('notes', BOB)}note`)).toBe('his'); + expect(localStorage.getItem('notification-center')).not.toBeNull(); + expect(localStorage.getItem('template-storage')).not.toBeNull(); + expect(clearFiles).not.toHaveBeenCalled(); + expect(clearHandoffs).not.toHaveBeenCalled(); + expect((await keys.getDeviceRecord(BOB))?.id).toBe(BOB_ROW); + expect(state.useOfflineStore.getState().enabled).toEqual([BOB]); + }); + + it('with offline off touches nothing but what\'s the account\'s own', async () => { + state.writeEnabled([]); + await openDb('smime-plugin-store'); + const handoffs = await import('@/lib/launch-handoff'); + const clearHandoffs = vi.spyOn(handoffs, 'clearHandoffs'); + const { pluginStoragePrefix } = await import('@/lib/plugin-sandbox/storage-scope'); + localStorage.setItem(`${pluginStoragePrefix('notes', ALICE)}note`, 'hers'); + localStorage.setItem('notification-center', '[{"title":"From Eve"}]'); + wipe.registerDeviceWipe(); + rejected.noteCredentialsRejected(ALICE, true); + await vi.waitFor(() => expect(localStorage.getItem(`${pluginStoragePrefix('notes', ALICE)}note`)).toBeNull()); + await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({})); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(await databases()).toContain('smime-plugin-store'); + expect(localStorage.getItem('notification-center')).not.toBeNull(); + expect(clearHandoffs).not.toHaveBeenCalled(); + }); + + it('records a real count for the audit, however it\'s named here', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + wipe.registerDeviceWipe(); + refuse(); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'rejected', unsent: 1 })); + }); + + it('doesn\'t delete on a refusal that hasn\'t lasted: one bad answer at a start only hides', async () => { + wipe.registerDeviceWipe(); + refuse(ALICE, false); + await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(0)); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + }); + + it('reaches a refusal from before the policy said so', async () => { + const { usePolicyStore } = await import('@/stores/policy-store'); + const none = { organisation: { devices: {} } }; + usePolicyStore.setState({ policy: none, rawPolicy: none, resolveFor: () => none } as never); + const stop = wipe.startDeviceWipe(); + refuse(); + await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(0)); + expect(purge.files).toBe(0); + await policyAsksForDeletion(); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + stop(); + }); + }); + + describe('from a running renewal', () => { + let heard: string[]; + + beforeEach(() => { + heard = []; + rejected.onCredentialsRejected((account) => heard.push(account)); + useAccountStore.setState({ accounts: [{ ...entry(ALICE, 0), authMode: 'oauth' }] } as never); + useAuthStore.setState({ authMode: 'oauth' }); + }); + + const token = (next: () => Response) => { respond = (url) => (url.includes('/api/auth/token') ? next() : noWipe()); }; + + it.each([ + ['a 500', () => json(500, { error: 'Internal server error' })], + ['a 503', () => json(503, { error: 'unavailable' })], + // What the route answers for a 400 invalid_client from the server. + ['a refusal other than invalid_grant', () => json(401, { error: 'Refresh failed' })], + ])('%s leaves the hook alone', async (_name, answer) => { + wipe.registerDeviceWipe(); + token(answer); + await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(heard).toEqual([]); + expect(purge.files).toBe(0); + expect(wipeState.readPendingWipes()).toEqual({}); + }); + + it('invalid_grant fires it, signs out and hides: nothing is deleted', async () => { + wipe.registerDeviceWipe(); + token(() => json(401, { error: 'invalid_grant' })); + await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); + expect(heard).toEqual([ALICE]); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); + expect(state.isEvicted(ALICE)).toBe(true); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); + }); + + it('the session limit stays out of it: no hook, and the data waits', async () => { + wipe.registerDeviceWipe(); + token(() => json(401, { error: 'session_expired', maxHours: 8 })); + await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); + expect(heard).toEqual([]); + await vi.waitFor(() => expect(state.isEvicted(ALICE)).toBe(true)); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(wipeState.readRefusedLogins()).toEqual({}); + }); + }); +}); + +describe('other windows', () => { + it('a window showing the wiped account reloads at the end, and only then', async () => { + const reload = vi.fn(); + vi.stubGlobal('location', { ...window.location, reload }); + const stop = wipe.startDeviceWipe(); + const other = new BroadcastChannel('bulwark-device-wipe'); + + other.postMessage({ account: ALICE, phase: 'start' }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(reload).not.toHaveBeenCalled(); + other.postMessage({ account: ALICE, phase: 'end' }); + await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1)); + + other.close(); + stop(); + vi.unstubAllGlobals(); + vi.stubGlobal('fetch', vi.fn(async () => noWipe())); + }); + + it.each(['start', 'end'])('a window showing another account lets go of the wiped one at the %s, in place', async (phase) => { + const reload = vi.fn(); + vi.stubGlobal('location', { ...window.location, reload }); + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + const stop = wipe.startDeviceWipe(); + const other = new BroadcastChannel('bulwark-device-wipe'); + await vi.waitFor(() => expect(sent.length).toBeGreaterThan(0)); + sent.length = 0; + + other.postMessage({ account: BOB, phase }); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined()); + + // Its own registry won't write it back, and the wiping window's work isn't done over here. + expect(reload).not.toHaveBeenCalled(); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); + expect(state.isEvicted(BOB)).toBe(false); + expect(store.removed).toEqual([]); + expect(purge.files).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + expect((await keys.getDeviceRecord(BOB))?.id).toBe(BOB_ROW); + expect(JSON.parse(localStorage.getItem('account-registry') ?? '{"state":{"accounts":[]}}').state.accounts.map((a: { id: string }) => a.id)).not.toContain(BOB); + + other.close(); + stop(); + vi.unstubAllGlobals(); + vi.stubGlobal('fetch', vi.fn(async () => noWipe())); + }); + + it('a wipe tells the others when it starts and when it ends', async () => { + const heard: unknown[] = []; + const other = new BroadcastChannel('bulwark-device-wipe'); + other.onmessage = (event) => heard.push(event.data); + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + purge.during = () => { heard.push('deleting'); }; + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + await vi.waitFor(() => expect(heard.at(-1)).toEqual({ account: ALICE, phase: 'end' })); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(heard).toContainEqual({ account: ALICE, phase: 'start' }); + other.close(); + }); + + it('the window that wipes doesn\'t hear its own message', async () => { + const reload = vi.fn(); + vi.stubGlobal('location', { ...window.location, reload }); + const stop = wipe.startDeviceWipe(); + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(reload).not.toHaveBeenCalled(); + stop(); + vi.unstubAllGlobals(); + vi.stubGlobal('fetch', vi.fn(async () => noWipe())); + }); +}); + +describe('what the desktop app keeps outside the page', () => { + const shell = { clearSurfaces: vi.fn(), clearClipboardFiles: vi.fn(async () => {}) }; + + beforeEach(() => { + shell.clearSurfaces.mockClear(); + shell.clearClipboardFiles.mockClear(); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { + version: 1, + platform: 'win32', + mode: 'instance', + os: { clearSurfaces: shell.clearSurfaces }, + clipboard: { clearFiles: shell.clearClipboardFiles }, + }; + }); + + it('goes with a remote wipe', async () => { + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(shell.clearSurfaces).toHaveBeenCalled(); + expect(shell.clearClipboardFiles).toHaveBeenCalled(); + }); + + it('goes with a wipe from settings', async () => { + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(shell.clearSurfaces).toHaveBeenCalled(); + expect(shell.clearClipboardFiles).toHaveBeenCalled(); + }); + + it('a shell without those calls wipes all the same', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); + await wipe.runDeviceGate(); + await waitForNoAccounts(); + expect(purge.files).toBeGreaterThan(0); + }); + + it('a call that fails leaves the wipe going', async () => { + shell.clearClipboardFiles.mockRejectedValueOnce(new Error('held by a program')); + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(purge.files).toBeGreaterThan(0); + }); +}); + +describe('the wipe from settings', () => { + it('takes everything, reports to the instance, and leaves no note: the user was there', async () => { + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(purge.files).toBeGreaterThan(0); + expect(useAccountStore.getState().accounts).toEqual([]); + expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'user', id: ROW }); + expect(wipeState.consumeWipeNote()).toBeNull(); + }); + + it('marks that the user signed out, so a provider session doesn\'t sign them straight back in', async () => { + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + expect(sessionStorage.getItem('signed_out')).toBe('true'); + }); +}); + +describe('the report and the deletion', () => { + beforeEach(() => { + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow()); + }); + + it('is queued before anything is deleted, and not sent until it\'s done', async () => { + let queuedWhenDeleting: string[] = []; + let sentWhenDeleting = -1; + purge.during = () => { + // The first time is the wipe's. The sign-out purges too, when all is done. + if (sentWhenDeleting >= 0) return; + queuedWhenDeleting = wipeState.readWipeConfirmations().map((entry) => entry.id); + sentWhenDeleting = sent.filter((call) => call.url.endsWith('/wiped')).length; + }; + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + expect(queuedWhenDeleting).toEqual([ROW]); + expect(sentWhenDeleting).toBe(0); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(1); + expect(wipeState.readWipeConfirmations()).toEqual([]); + }); + + it('another contact during the wipe doesn\'t send it early', async () => { + let early = -1; + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + registry.onDeviceWipe(async () => { + await wipe.checkInDue(true); + early = sent.filter((call) => call.url.endsWith('/wiped')).length; + }); + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + expect(early).toBe(0); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(1); + }); + + it('a deletion that fails isn\'t reported as done: it stays pending and is finished at the next start', async () => { + purge.fail = true; + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + expect(wipeState.readPendingWipes()).toEqual({ [ALICE]: 'admin' }); + expect(wipeState.readWipeConfirmations().map((entry) => entry.id)).toEqual([ROW]); + // Signed out all the same: nothing more is shown. + expect(useAccountStore.getState().accounts).toEqual([]); + + purge.fail = false; + await wipe.finishInterruptedWipes(); + expect(purge.files).toBeGreaterThan(0); + expect(wipeState.readPendingWipes()).toEqual({}); + expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ id: ROW, reason: 'admin' }); + }); + + it('a plugin database a frame holds open isn\'t reported as wiped until it\'s gone', async () => { + const held = await new Promise((resolve, reject) => { + const request = indexedDB.open('smime-plugin-store', 1); + request.onupgradeneeded = () => { request.result.createObjectStore('s'); }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + expect(Object.keys(wipeState.readBlockedDatabases())).toEqual(['smime-plugin-store']); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + expect(wipeState.readWipeConfirmations().map((entry) => entry.id)).toEqual([ROW]); + + held.close(); + await wipe.finishInterruptedWipes(); + await wipe.checkInDue(true); + await vi.waitFor(() => expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(1)); + expect(wipeState.readBlockedDatabases()).toEqual({}); + expect(await databases()).not.toContain('smime-plugin-store'); + }); +}); + +describe('two wipes at once', () => { + beforeEach(async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.openAccountKey(BOB); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + respond = () => json(200, { ok: true }); + }); + + it('neither puts back the row the other just removed', async () => { + await Promise.all([wipe.wipeAccountFromDevice(ALICE, 'admin'), wipe.wipeAccountFromDevice(BOB, 'admin')]); + expect(await keys.listDeviceRecords()).toEqual([]); + expect(useAccountStore.getState().accounts).toEqual([]); + expect(wipeState.readPendingWipes()).toEqual({}); + expect(sent.filter((call) => call.url.endsWith('/wiped')).map((call) => call.body.id).sort()).toEqual([ROW, BOB_ROW].sort()); + }); + + it('one runs after the other: they don\'t overlap', async () => { + const running: string[] = []; + let overlapped = false; + registry.onDeviceWipe(async (account) => { + if (running.length > 0) overlapped = true; + running.push(String(account)); + await new Promise((resolve) => setTimeout(resolve, 20)); + running.pop(); + }); + await Promise.all([wipe.wipeAccountFromDevice(ALICE, 'admin'), wipe.wipeAccountFromDevice(BOB, 'admin')]); + expect(overlapped).toBe(false); + }); + + it('a wipe heard twice is done once: the second finds the row gone', async () => { + let cleaned = 0; + registry.onDeviceWipe(() => { cleaned += 1; }); + await wipe.wipeAccountFromDevice(ALICE, 'admin', { ifRow: ROW }); + expect(cleaned).toBe(1); + const reports = sent.filter((call) => call.url.endsWith('/wiped')).length; + expect(wipeState.consumeWipeNote()).toEqual({ unsent: 0 }); + await wipe.wipeAccountFromDevice(ALICE, 'admin', { ifRow: ROW }); + expect(cleaned).toBe(1); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(reports); + expect(wipeState.consumeWipeNote()).toBeNull(); + expect(wipeState.readPendingWipes()).toEqual({}); + }); + + it('takes the Web Lock where the browser has one', async () => { + const names: string[] = []; + vi.stubGlobal('navigator', Object.assign(Object.create(navigator), { + locks: { request: async (name: string, work: () => Promise) => { names.push(name); return work(); } }, + })); + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + vi.stubGlobal('navigator', Object.getPrototypeOf(navigator)); + expect(names).toContain('bulwark-device-wipe'); + }); +}); + +describe('a sign-out', () => { + beforeEach(() => { + respond = () => json(200, { ok: true }); + }); + + it('drops the row with its secret', async () => { + wipe.registerDeviceWipe(); + await useAuthStore.getState().logout({ endProviderSession: false }); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toEqual({ id: ROW, secret: SECRET, drop: true })); + expect(wipeState.readWipeConfirmations()).toEqual([]); + }); + + it('has its row on the way before the key database goes with the rest', async () => { + respond = () => json(503, {}); + let queuedAtPurge: string[] | null = null; + purge.during = () => { queuedAtPurge ??= wipeState.readWipeConfirmations().map((entry) => entry.id); }; + wipe.registerDeviceWipe(); + await useAuthStore.getState().logout({ endProviderSession: false }); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + expect(queuedAtPurge).toEqual([ROW]); + }); + + it('removing another account drops its row too, and keeps this one\'s', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + wipe.registerDeviceWipe(); + useAuthStore.getState().removeAccount(BOB); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toEqual({ id: BOB_ROW, secret: SECRET, drop: true })); + expect(await keys.getDeviceRecord(BOB)).toBeNull(); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW); + }); + + it('a sign-out that keeps the data keeps the row: an admin can still reach it', async () => { + wipe.registerDeviceWipe(); + await useAuthStore.getState().logout({ endProviderSession: false, keepLocalData: true }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW); + }); + + it('gets through at the next contact when the instance was out of reach', async () => { + respond = () => json(503, {}); + wipe.registerDeviceWipe(); + await useAuthStore.getState().logout({ endProviderSession: false }); + await vi.waitFor(() => expect(wipeState.readWipeConfirmations().map((entry) => entry.id)).toEqual([ROW])); + respond = () => json(200, { ok: true }); + await wipe.checkInDue(true); + await vi.waitFor(() => expect(wipeState.readWipeConfirmations()).toEqual([])); + }); +}); + +describe('counting what a wipe loses', () => { + const offline = () => import('../offline-mode'); + + it('per account: each row reports its own, not the device\'s total', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.openAccountKey(BOB); + await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 }); + await queue.queueSend(ALICE, { subject: 'One', to: [] }, { kind: 'email', args: [] }); + await queue.queueSend(ALICE, { subject: 'Two', to: [] }, { kind: 'email', args: [] }); + respond = () => json(200, { ok: true }); + await wipe.wipeWholeDevice('user'); + const reports = sent.filter((call) => call.url.endsWith('/wiped')).map((call) => call.body); + expect(reports.find((body) => body.id === ROW)).toMatchObject({ unsent: 2 }); + expect(reports.find((body) => body.id === BOB_ROW)).toMatchObject({ unsent: 0 }); + }); + + it('a store that can\'t answer gives unknown, not nought', async () => { + store.failArea = () => true; + expect(await (await offline()).unsentForWipe(ALICE)).toBeNull(); + }); + + it('a store that doesn\'t answer in time gives unknown, not nought', async () => { + const api = await import('@/lib/store/client/api'); + vi.spyOn(api.getStore(), 'area').mockImplementation(() => new Promise(() => {})); + expect(await (await offline()).unsentForWipe(ALICE, 20)).toBeNull(); + vi.restoreAllMocks(); + }); + + it('waits longer for it than a sign-out does', async () => { + expect((await offline()).UNSENT_FOR_WIPE_MS).toBeGreaterThanOrEqual(10_000); + }); + + it('reports an unknown count as such, and says so at the next sign-in', async () => { + store.failArea = () => true; + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ unsent: null }); + expect(wipeState.consumeWipeNote()).toEqual({ unsent: 0, unknown: true }); + }); + + it('counts an account nobody can see too: it goes with the device', async () => { + await queue.queueSend(ALICE, { subject: 'Hidden', to: [] }, { kind: 'email', args: [] }); + state.markEvicted(ALICE); + expect(await (await offline()).unsentForWipe('all')).toBe(1); + }); +}); + +describe('what else goes with a wipe', () => { + const ACCOUNT_KEYS = [ + 'pro-tabs', 'search-history-storage', 'files-favorites', 'files-recent-files', 'files-last-parent-id', 'files-path-stack', + 'bulwark-delegate-names', 'calendar-notification-storage', 'identity-storage', 'contact-storage', 'calendar-storage', + 'webdav-favorites', 'webdav-recent-files', 'webdav-last-path', 'template-storage', + ]; + let closed: string[]; + let unsubscribed: number; + let deletedCaches: string[]; + + function plant(): void { + for (const key of ACCOUNT_KEYS) localStorage.setItem(key, 'x'); + localStorage.setItem('settings-storage', 'device preference'); + localStorage.setItem('theme-storage', 'device preference'); + localStorage.setItem('email-snapshot', JSON.stringify({ account: ALICE, emails: [{ subject: 'From Eve' }] })); + sessionStorage.setItem('bulwark-rescued-drafts', JSON.stringify([{ subject: 'Half written', body: 'text' }])); + } + + beforeEach(() => { + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + closed = []; + unsubscribed = 0; + deletedCaches = []; + plant(); + const registration = { + getNotifications: async () => [{ title: 'From Eve', close() { closed.push(this.title); } }], + pushManager: { getSubscription: async () => ({ unsubscribe: async () => { unsubscribed += 1; return true; } }) }, + }; + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { getRegistration: async () => registration, controller: null } }); + vi.stubGlobal('caches', { + keys: async () => ['bulwark-push-state-v1', 'unrelated'], + delete: async (name: string) => { deletedCaches.push(name); return true; }, + open: async () => ({ put: async () => {} }), + }); + }); + + afterEach(() => { + delete (navigator as { serviceWorker?: unknown }).serviceWorker; + vi.unstubAllGlobals(); + }); + + it('an admin wipe takes them, closes what\'s on screen, and the last account ends Web Push', async () => { + const { saveForwardApprovals, forwardApprovals } = await import('@/lib/plugin-sandbox/forward-approvals'); + saveForwardApprovals('forwarder', ALICE, ['eve@example.org']); + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + for (const key of ACCOUNT_KEYS) expect([key, localStorage.getItem(key)]).toEqual([key, null]); + expect(localStorage.getItem('email-snapshot')).toBeNull(); + expect(sessionStorage.getItem('bulwark-rescued-drafts')).toBeNull(); + expect(forwardApprovals('forwarder', ALICE)).toEqual([]); + expect(closed).toEqual(['From Eve']); + expect(deletedCaches).toContain('bulwark-push-state-v1'); + expect(unsubscribed).toBe(1); + expect(localStorage.getItem('settings-storage')).toBe('device preference'); + expect(localStorage.getItem('theme-storage')).toBe('device preference'); + }); + + it('with another account left, Web Push stays: it\'s shared', async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + await wipe.wipeAccountFromDevice(ALICE, 'admin'); + expect(closed).toEqual(['From Eve']); + expect(unsubscribed).toBe(0); + // No full sign-out comes along to clean up behind it. A store may write itself back, empty. + for (const key of ['pro-tabs', 'search-history-storage', 'files-favorites', 'files-recent-files', 'files-last-parent-id', 'files-path-stack', + 'bulwark-delegate-names', 'calendar-notification-storage', 'webdav-favorites', 'webdav-recent-files', 'webdav-last-path', 'template-storage']) { + expect([key, localStorage.getItem(key)]).not.toEqual([key, 'x']); + } + expect(deletedCaches).toContain('bulwark-push-state-v1'); + }); + + it('a wipe from settings takes them too', async () => { + await wipe.wipeWholeDevice('user'); + for (const key of ACCOUNT_KEYS) expect([key, localStorage.getItem(key)]).toEqual([key, null]); + expect(unsubscribed).toBe(1); + }); + + it('a deletion after a refused login leaves all of it: only the account\'s own snapshot goes', async () => { + await policyAsksForDeletion(); + wipe.registerDeviceWipe(); + refuse(); + await vi.waitFor(() => expect(store.removed.length + purge.files).toBeGreaterThan(0)); + await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({})); + expect(localStorage.getItem('email-snapshot')).toBeNull(); + for (const key of ACCOUNT_KEYS) expect([key, localStorage.getItem(key)]).toEqual([key, 'x']); + expect(closed).toEqual([]); + expect(unsubscribed).toBe(0); + }); +}); diff --git a/lib/offline/__tests__/evicted-secrets.test.ts b/lib/offline/__tests__/evicted-secrets.test.ts new file mode 100644 index 000000000..8bfbbd8c2 --- /dev/null +++ b/lib/offline/__tests__/evicted-secrets.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// A dropped account's plugin secrets, on the desktop app's own server. It has no +// cookie left, so what the account store wrote down at the time is all there is. + +const ALICE = 'alice@mail.example.test'; +const entry = { + id: ALICE, label: ALICE, serverUrl: 'https://mail.example.test', username: 'alice', authMode: 'basic', rememberMe: true, + displayName: ALICE, email: ALICE, lastLoginAt: 1, isConnected: true, hasError: false, isDefault: true, cookieSlot: 0, avatarColor: '#000', +}; + +async function load(desktop: boolean) { + vi.resetModules(); + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', desktop ? '1' : ''); + const { useAccountStore } = await import('@/stores/account-store'); + const state = await import('../state'); + const { forgetEvictedSecrets } = await import('../evicted-secrets'); + useAccountStore.setState({ accounts: [entry], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + state.writeEnabled([ALICE]); + return { useAccountStore, state, forgetEvictedSecrets }; +} + +let calls: Array<{ url: string; method?: string }>; +let answer: () => Response; + +beforeEach(() => { + localStorage.clear(); + calls = []; + answer = () => new Response('{"ok":true}', { status: 200 }); + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => { + calls.push({ url: String(input), method: init?.method }); + return answer(); + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('a dropped account\'s plugin secrets', () => { + it('in the desktop app go by who it was, with no cookie', async () => { + const { useAccountStore, state, forgetEvictedSecrets } = await load(true); + useAccountStore.getState().removeAccount(ALICE, 'evicted'); + expect(state.readEvictedOwners()[ALICE]).toEqual({ username: 'alice', serverUrl: 'https://mail.example.test' }); + expect(JSON.stringify(state.readEvictedOwners())).not.toContain('password'); + + await forgetEvictedSecrets(ALICE); + expect(calls).toHaveLength(1); + const url = new URL(calls[0].url, 'https://desktop.local'); + expect(calls[0].method).toBe('DELETE'); + expect(url.pathname).toBe('/api/plugin-secrets'); + expect(Object.fromEntries(url.searchParams)).toEqual({ all: 'true', username: 'alice', server: 'https://mail.example.test' }); + expect(state.readEvictedOwners()).toEqual({}); + }); + + it('are asked for again when the server couldn\'t', async () => { + const { useAccountStore, state, forgetEvictedSecrets } = await load(true); + useAccountStore.getState().removeAccount(ALICE, 'evicted'); + answer = () => new Response('{}', { status: 503 }); + await forgetEvictedSecrets(ALICE); + expect(Object.keys(state.readEvictedOwners())).toEqual([ALICE]); + }); + + it('stay when the account signs in again, and nobody remembers it was dropped', async () => { + const { useAccountStore, state, forgetEvictedSecrets } = await load(true); + useAccountStore.getState().removeAccount(ALICE, 'evicted'); + useAccountStore.getState().addAccount(entry as never); + expect(state.readEvictedOwners()).toEqual({}); + await forgetEvictedSecrets(null); + expect(calls).toEqual([]); + }); + + it('on an instance aren\'t this device\'s: nothing is written down or asked', async () => { + const { useAccountStore, state, forgetEvictedSecrets } = await load(false); + useAccountStore.getState().removeAccount(ALICE, 'evicted'); + expect(state.readEvictedOwners()).toEqual({}); + await forgetEvictedSecrets(null); + expect(calls).toEqual([]); + }); +}); diff --git a/lib/offline/credentials-rejected.ts b/lib/offline/credentials-rejected.ts index a42282998..8a4162723 100644 --- a/lib/offline/credentials-rejected.ts +++ b/lib/offline/credentials-rejected.ts @@ -1,12 +1,14 @@ /** - * The server really refused this login (`invalid_grant`), which the auth store - * follows with a sign-out that wipes the account's local data. An outage or any - * other refusal doesn't come here, and keeps the data. HOOK: lib/store/wipe - * registers here for what else a permanent rejection should do. Free of the - * stores, so the auth store can call it. + * The server refused this login: `invalid_grant`, a lasting 401 after a fresh try + * (both `certain`), or a 401 at a connect that then failed (not, it's one answer). + * The auth store follows with a sign-out that hides the account's data, which + * waits for the new password. The organisation's session limit, an outage or any + * other refusal doesn't come here. lib/offline/device-wipe.ts listens, to keep the + * count for the sign-in page and to delete at once where the organisation asks for + * that. Free of the stores, so the auth store can call it. */ -type Listener = (accountId: string) => void; +type Listener = (accountId: string, certain: boolean) => void; const listeners = new Set(); @@ -15,10 +17,10 @@ export function onCredentialsRejected(listener: Listener): () => void { return () => listeners.delete(listener); } -export function noteCredentialsRejected(accountId: string): void { +export function noteCredentialsRejected(accountId: string, certain = true): void { for (const listener of [...listeners]) { try { - listener(accountId); + listener(accountId, certain); } catch { // A sign-out must go on whatever a listener does. } diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts new file mode 100644 index 000000000..a1989aa44 --- /dev/null +++ b/lib/offline/device-wipe.ts @@ -0,0 +1,727 @@ +/** + * Everything that takes an account's data off this device, and how a device + * hears that it should: + * + * - the instance's registry (lib/devices): a wipe an admin asked for, heard at + * check-in. At a start the check-in runs before the local copy is shown. + * - the user, from settings. + * - the organisation, when it asks for deletion at once after a refused login + * (`organisation.devices.wipeOnRefusedLogin`). + * + * A refused login on its own deletes nothing. The account signs out and its data + * hides until someone signs in with the new password, or for 30 days + * (EVICTED_KEEP_MS). What this file keeps for it is a number, for the sign-in page. + * The usual cause is a password changed on another device, and deleting there + * would lose unsent mail for very little protection against a thief. A deletion + * the organisation asked for takes the account's own data and nothing shared. + * + * Only a clear word wipes. An outage, a 500, an unknown answer: nothing happens. + * + * The desktop app's `WipeOfflineData` policy isn't here: its main process clears + * every partition itself (repos/desktop, src/main/features/local-wipe.ts), with no + * page taking part, so nothing a page runs could say it was done without doing it. + * + * Why a device id doesn't survive a wipe: the row keeps the history at the + * instance, and after a sign-in this is a device starting over, which the admin + * can wipe again. Keeping the id here would leave something of the account on a + * device that was told to remove it. What stays until the instance has heard is + * the row's id and secret, in lib/devices/wipe-state.ts. + * + * Accounts without cookies: a session cookie goes with the browser and the + * sealed one lasts 30 days, but a check-in only needs the secret, so a device + * still hears its wipe. One that lost its key database has nothing left to wipe + * and can't be told. Its row keeps saying the wipe is waiting. + * + * Wipes run one at a time across every window of this browser, under a Web Lock: + * the last account's wipe deletes the key database with every row in it, so a + * second wipe going on at that moment would put back a row just removed. + */ + +import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; +import { sweepPrivateExports } from '@/lib/mail-export/sinks'; +import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; +import { fileStorage } from '@/lib/plugin-storage'; +import { clearHandoffs } from '@/lib/launch-handoff'; +import { apiFetch } from '@/lib/browser-navigation'; +import { SW_SCOPE } from '@/lib/app-update'; +import { devicesForAccount, devicesForNoOne } from '@/lib/device-policy'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { clearRescuedDrafts } from '@/lib/popout/rescued-drafts'; +import { isPopoutWindow } from '@/lib/popout/window-role'; +import { PUSH_STATE_CACHE } from '@/lib/push-preferences'; +import { ACCOUNT_STORAGE_KEYS, onBeforeSignOut } from '@/lib/sign-out-cleanup'; +import { + checkInWithInstance, + registerWithInstance, + reportWipeToInstance, + type RegisteredRow, +} from '@/lib/devices/client'; +import type { WipeReason } from '@/lib/devices/types'; +import { + addWipeNote, + clearWipePending, + dropRefusedLogin, + dropWipeConfirmation, + markWipePending, + queueWipeConfirmation, + readBlockedDatabases, + readPendingWipes, + readRefusedLogins, + readWipeConfirmations, + setRefusedCount, + WHOLE_DEVICE, + writeBlockedDatabases, +} from '@/lib/devices/wipe-state'; +import { + deleteDeviceRecord, + getDeviceRecord, + listDeviceRecords, + putDeviceRecord, + type DeviceRecord, +} from '@/lib/store/client/keys'; +import { onAccountRemoved, useAccountStore, type AccountEntry } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { onCredentialsRejected } from './credentials-rejected'; +import { forgetEvictedSecrets } from './evicted-secrets'; +import { confirmLoss, registerOfflineHooks, unsentForWipe, wipeAccount, wipeEverything } from './offline-mode'; +import { clearEvicted, forgetMailSnapshot, isEvicted, readEvicted, serverKey, useOfflineStore } from './state'; +import { runWipeCleaners } from './wipe-registry'; + +/** How long a start holds the local copy back for the instance's answer. Offline, it doesn't wait at all. */ +export const BOOT_CHECK_IN_BUDGET_MS = 1_000; +/** A check-in of each device row, at most this often. */ +export const CHECK_IN_EVERY_MS = 60 * 60 * 1000; +/** How often a running page looks whether something is due. */ +const TICK_MS = 60 * 1000; +/** How long a wipe waits to tell the instance before it goes on: it tries again at the next contact. */ +const REPORT_BUDGET_MS = 3_000; +/** How long a wipe waits for one database a running plugin frame holds open. */ +const DATABASE_BUDGET_MS = 2_000; + +const WIPE_CHANNEL = 'bulwark-device-wipe'; +const WIPE_LOCK = 'bulwark-device-wipe'; + +/** The webmail's own databases. Any other on this origin was made by a privileged plugin. */ +const APP_DATABASES = new Set([ + 'bulwark-store', + 'bulwark-plugins', + 'bulwark-company-calendars', + 'bulwark-mail-import', + 'bulwark-offline', +]); +/** Deleted by name too, for browsers without `indexedDB.databases()`. */ +const KNOWN_PLUGIN_DATABASES = ['smime-plugin-store', 'bulwark-openpgp', 'smime-capability-probe', 'openpgp-capability-probe']; + +/** + * What every account shared and can't be told apart by account, on top of what a + * full sign-out removes. `template-storage` is here though a sign-out keeps it: + * with settings sync off it's the user's own work and only here, which is why a + * sign-out leaves it, but a template can hold anything, and a device told to remove + * everything keeps no text. The question before a wipe from settings names it. + */ +const SHARED_LOCAL_KEYS = [ + ...ACCOUNT_STORAGE_KEYS, + 'notification-center', + 'plugin-oauth-states', + 'webmail_usernames', + 'global-search', + 'favicon-refresh', + 'template-storage', +]; +/** The WebDAV area's favourites, recent files and last folder. */ +const SHARED_LOCAL_PREFIXES = ['webdav-']; + +const running = new Map>(); + +let lockQueue: Promise = Promise.resolve(); + +/** One wipe at a time. Without Web Locks: at least in this window. */ +function underWipeLock(work: () => Promise): Promise { + if (typeof navigator !== 'undefined' && navigator.locks) return navigator.locks.request(WIPE_LOCK, work); + const turn = lockQueue.then(work, work); + lockQueue = turn.catch(() => {}); + return turn; +} + +// ---- Removing it ---- + +function deleteDatabase(name: string): Promise { + return new Promise((resolve) => { + let request: IDBOpenDBRequest; + const timer = setTimeout(() => resolve(false), DATABASE_BUDGET_MS); + const finish = (done: boolean) => { + clearTimeout(timer); + resolve(done); + }; + try { + request = indexedDB.deleteDatabase(name); + } catch { + finish(true); + return; + } + request.onsuccess = () => finish(true); + request.onerror = () => finish(false); + }); +} + +/** + * Plugin-owned databases. S/MIME and OpenPGP keys aren't kept per account, so a + * wipe of one account takes everyone's: keeping them would leave a wiped user's + * private key behind. They're encrypted and come back from the user's own backup. + * One a running frame holds open stays on the list, and the wipe isn't reported + * as done until it's gone. + */ +async function deletePluginDatabases(): Promise { + if (typeof indexedDB === 'undefined') return; + const tries = readBlockedDatabases(); + const names = new Set([...KNOWN_PLUGIN_DATABASES, ...Object.keys(tries)]); + try { + for (const { name } of await indexedDB.databases()) { + if (name && !APP_DATABASES.has(name)) names.add(name); + } + } catch { + // No list in this browser: the names above. + } + const blocked: Record = {}; + await Promise.all([...names].map(async (name) => { + if (!(await deleteDatabase(name))) blocked[name] = (tries[name] ?? 0) + 1; + })); + writeBlockedDatabases(blocked); +} + +/** + * What the desktop app keeps outside this page's storage: attachment copies, files + * on the clipboard, toasts, tray and jump list. Each is feature detected, since an + * older shell has none of them and a browser no bridge. + */ +async function clearShellFiles(): Promise { + const bridge = getDesktopBridge() as unknown as { + os?: { clearSurfaces?: () => void }; + clipboard?: { clearFiles?: () => void | Promise }; + } | null; + bridge?.os?.clearSurfaces?.(); + await bridge?.clipboard?.clearFiles?.(); +} + +/** A running frame is gone at the next load. Before any plugin starts again. */ +async function retryBlockedDatabases(): Promise { + if (Object.keys(readBlockedDatabases()).length > 0) await deletePluginDatabases(); +} + +/** The accounts' connected plugin accounts, which only sit on this device in the desktop app's own server. */ +async function forgetPluginAccounts(account: string | null): Promise { + if (!IS_DESKTOP_BUILD) return; + const accounts = useAccountStore.getState().accounts; + const slots = (account === null ? accounts : accounts.filter((entry) => entry.id === account)).map((entry) => entry.cookieSlot ?? 0); + await Promise.all(slots.map((slot) => apiFetch('/api/plugin-secrets?all=true', { + method: 'DELETE', + headers: { 'X-JMAP-Cookie-Slot': String(slot) }, + signal: AbortSignal.timeout(REPORT_BUDGET_MS), + }).catch(() => {}))); + // An account dropped earlier has no cookie left to ask with. + await forgetEvictedSecrets(account); +} + +async function forgetPluginSettings(): Promise { + const { usePluginStore } = await import('@/stores/plugin-store'); + usePluginStore.setState((state) => ({ plugins: state.plugins.map((plugin) => ({ ...plugin, settings: {} })) })); +} + +function removeLocalKeys(keys: string[], prefixes: string[]): void { + try { + const doomed = [...keys]; + for (let index = 0; index < localStorage.length; index += 1) { + const key = localStorage.key(index); + if (key && prefixes.some((prefix) => key.startsWith(prefix))) doomed.push(key); + } + for (const key of doomed) localStorage.removeItem(key); + } catch { + /* storage unavailable */ + } +} + +async function pushRegistration(): Promise { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return null; + return (await navigator.serviceWorker.getRegistration(SW_SCOPE).catch(() => null)) ?? null; +} + +/** A sender and a subject can sit on screen long after the mail is gone. */ +async function closeShownNotifications(): Promise { + const registration = await pushRegistration(); + for (const notification of await registration?.getNotifications?.() ?? []) notification.close(); +} + +/** The subscription is the browser's, shared by every account on this origin. */ +async function endWebPushIfNobodyLeft(): Promise { + if (useAccountStore.getState().accounts.length > 0) return; + const registration = await pushRegistration(); + const subscription = await registration?.pushManager?.getSubscription(); + await subscription?.unsubscribe(); +} + +/** What's the account's own: a deletion after a refused login goes no further than this. */ +async function scrubAccount(account: string): Promise { + await runWipeCleaners(account); + await forgetPluginAccounts(account).catch(() => {}); + clearPluginStorageForAccount(account); + forgetMailSnapshot(account); +} + +/** Everything accounts share, or that can't be told apart by account. */ +async function scrubShared(): Promise { + await clearShellFiles().catch(() => {}); + await deletePluginDatabases(); + await forgetPluginSettings().catch(() => {}); + await fileStorage.clearFiles().catch(() => {}); + await clearAllCheckpoints().catch(() => {}); + await sweepPrivateExports({ force: true }).catch(() => {}); + try { + indexedDB.deleteDatabase('bulwark-company-calendars'); + } catch { + /* storage unavailable */ + } + clearHandoffs(); + clearRescuedDrafts(); + const { useNotificationCenterStore } = await import('@/stores/notification-center-store'); + useNotificationCenterStore.getState().clear(); + await closeShownNotifications().catch(() => {}); + if (typeof caches !== 'undefined') await caches.delete(PUSH_STATE_CACHE).catch(() => {}); + removeLocalKeys(SHARED_LOCAL_KEYS, SHARED_LOCAL_PREFIXES); +} + +/** + * Sends what's ready to go. A wipe's confirmation is queued before anything is + * deleted, so that a crash can't lose it, and isn't sent until the deletion is + * done: `finished` is the wipe that just got there. What's still pending, or a + * plugin database that's still there, isn't a wipe that can be reported. + */ +async function flushConfirmations(finished?: string): Promise { + const pending = readPendingWipes(); + const blocked = Object.keys(readBlockedDatabases()).length > 0; + const deviceOpen = WHOLE_DEVICE in pending && finished !== WHOLE_DEVICE; + const ready = readWipeConfirmations().filter((entry) => { + if (entry.drop) return true; + if (deviceOpen || (blocked && entry.reason !== 'rejected')) return false; + return !entry.account || !(entry.account in pending) || entry.account === finished; + }); + await Promise.all(ready.map(async (entry) => { + if (await reportWipeToInstance(entry, AbortSignal.timeout(REPORT_BUDGET_MS))) dropWipeConfirmation(entry.id); + })); +} + +function signalSignedOut(): void { + // The login page would otherwise sign straight back in through a provider session. + try { + sessionStorage.setItem('signed_out', 'true'); + } catch { + /* noop */ + } +} + +/** One object for sending and hearing: a channel doesn't hear itself, so this window doesn't reload. */ +let wipeChannel: BroadcastChannel | null = null; + +function otherWindows(): BroadcastChannel | null { + try { + wipeChannel ??= new BroadcastChannel(WIPE_CHANNEL); + return wipeChannel; + } catch { + return null; + } +} + +type WipePhase = 'start' | 'end'; + +function tellOtherWindows(account: string | null, phase: WipePhase): void { + otherWindows()?.postMessage({ account, phase }); +} + +async function signOutAccount(account: string): Promise { + const auth = useAuthStore.getState(); + signalSignedOut(); + if (auth.activeAccountId === account) await auth.logout({ endProviderSession: false }); + else auth.removeAccount(account, 'explicit'); +} + +/** The key database goes with the last account's store, and the other accounts' rows with it. */ +async function putRowsBack(rows: DeviceRecord[], wiped: string): Promise { + const still = await listDeviceRecords().catch(() => [] as DeviceRecord[]); + for (const row of rows) { + if (row.account !== wiped && !still.some((entry) => entry.account === row.account)) await putDeviceRecord(row).catch(() => {}); + } +} + +export interface WipeAccountOptions { + /** Heard for this row: another window that got there first has removed it, and there's nothing to do. */ + ifRow?: string; +} + +/** + * Takes one account off this device. A refused login's sign-out is the auth + * store's own, and what it deletes is only the account's: no shared key database, + * no plugin settings, and with nothing kept here nothing of offline mode either. + */ +export function wipeAccountFromDevice(account: string, reason: WipeReason, options: WipeAccountOptions = {}): Promise { + const under = running.get(account); + if (under) return under; + // Now, not when the lock comes: a window closed while it waits still finishes this. + markWipePending(account, reason); + const work = underWipeLock(() => eraseAccount(account, reason, options)).finally(() => running.delete(account)); + running.set(account, work); + return work; +} + +async function eraseAccount(account: string, reason: WipeReason, options: WipeAccountOptions): Promise { + // Another window finished it while this one waited for the lock. + if (!(account in readPendingWipes())) return; + const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]); + const record = records.find((entry) => entry.account === account); + if (options.ifRow && record?.id !== options.ifRow) { + clearWipePending(account); + return; + } + tellOtherWindows(account, 'start'); + registerOfflineHooks(); + + const ownOnly = reason === 'rejected'; + const unsent = await unsentForWipe(account); + if (record && !readWipeConfirmations().some((entry) => entry.id === record.id)) { + queueWipeConfirmation({ account, id: record.id, secret: record.secret, reason, unsent }); + if (reason === 'admin') addWipeNote(unsent); + } + + const shown = useOfflineStore.getState(); + const keepsMail = shown.enabled.includes(account) || shown.outbox.some((row) => row.account === account) || shown.localDrafts.some((row) => row.account === account); + if (!ownOnly) confirmLoss(account); + let deleted = true; + if (!ownOnly || keepsMail) { + // Tried again at the next start: the row keeps waiting until then. + await wipeAccount(account).catch(() => { deleted = false; }); + } + clearEvicted(account); + dropRefusedLogin(account); + await scrubAccount(account).catch(() => {}); + if (!ownOnly) await scrubShared().catch(() => {}); + + await deleteDeviceRecord(account).catch(() => {}); + await putRowsBack(records, account); + + await flushConfirmations(deleted ? account : undefined); + if (!ownOnly) { + await signOutAccount(account); + await endWebPushIfNobodyLeft().catch(() => {}); + } + if (deleted) clearWipePending(account); + tellOtherWindows(account, 'end'); +} + +/** Everything on this device, for everyone. The app's device preferences stay: they hold no mail. */ +export function wipeWholeDevice(reason: WipeReason): Promise { + const under = running.get(WHOLE_DEVICE); + if (under) return under; + markWipePending(WHOLE_DEVICE, reason); + const work = underWipeLock(() => eraseDevice(reason)).finally(() => running.delete(WHOLE_DEVICE)); + running.set(WHOLE_DEVICE, work); + return work; +} + +async function eraseDevice(reason: WipeReason): Promise { + if (!(WHOLE_DEVICE in readPendingWipes())) return; + tellOtherWindows(null, 'start'); + registerOfflineHooks(); + const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]); + const accounts = [...new Set([...useOfflineStore.getState().enabled, ...records.map((record) => record.account)])]; + const counts = new Map(await Promise.all(accounts.map(async (account) => [account, await unsentForWipe(account)] as const))); + const queued = readWipeConfirmations(); + for (const record of records) { + if (queued.some((entry) => entry.id === record.id)) continue; + queueWipeConfirmation({ account: record.account, id: record.id, secret: record.secret, reason, unsent: counts.get(record.account) ?? 0 }); + } + if (reason === 'admin') { + const each = [...counts.values()]; + addWipeNote(each.includes(null) ? null : (each as number[]).reduce((sum, count) => sum + count, 0)); + } + + confirmLoss('all'); + await runWipeCleaners(null); + await forgetPluginAccounts(null).catch(() => {}); + clearAllPluginStorage(); + await scrubShared().catch(() => {}); + let deleted = true; + await wipeEverything().catch(() => { deleted = false; }); + for (const account of Object.keys(readEvicted())) clearEvicted(account); + for (const account of Object.keys(readRefusedLogins())) dropRefusedLogin(account); + await flushConfirmations(deleted ? WHOLE_DEVICE : undefined); + signalSignedOut(); + await useAuthStore.getState().logoutAll({ endProviderSession: false }); + await endWebPushIfNobodyLeft().catch(() => {}); + if (deleted) clearWipePending(WHOLE_DEVICE); + tellOtherWindows(null, 'end'); +} + +// ---- A sign-out: the row has nothing left to look after ---- + +function queueDrop(row: Pick): void { + queueWipeConfirmation({ id: row.id, secret: row.secret, reason: 'user', unsent: 0, drop: true }); +} + +async function dropRows(scope: string): Promise { + const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]); + const rows = scope === 'all' ? records : records.filter((record) => record.account === scope); + if (rows.length === 0) return; + for (const row of rows) { + queueDrop(row); + await deleteDeviceRecord(row.account).catch(() => {}); + } + await flushConfirmations(); +} + +// ---- Hearing about it ---- + +/** Whether this device has an instance to talk to. The desktop app's own server has no admin to ask. */ +function hasInstance(): boolean { + if (IS_DESKTOP_BUILD) return false; + return getDesktopBridge()?.mode !== 'standalone'; +} + +function wantsRow(account: { id: string; awaitingSignIn?: boolean; impersonated?: boolean }): boolean { + if (account.awaitingSignIn || account.impersonated) return false; + return getDesktopBridge()?.mode === 'instance' || useOfflineStore.getState().enabled.includes(account.id); +} + +const lastCheckIn = new Map(); +const lastRegisterTry = new Map(); +/** An older instance, or a session cookie that isn't back yet: not a request a minute. */ +const REGISTER_RETRY_MS = 15 * 60 * 1000; +let checkInFlight: Promise | null = null; + +async function checkInRecord(record: DeviceRecord, now: number): Promise { + lastCheckIn.set(record.account, now); + const answer = await checkInWithInstance(record, AbortSignal.timeout(REPORT_BUDGET_MS * 3)); + if (answer === 'wipe') { + await wipeAccountFromDevice(record.account, 'admin', { ifRow: record.id }); + return; + } + // The row is gone, or wiped already: what it was for is over. The account stays, and gets a new one. + if (answer === 'forgotten' || answer === 'wiped') { + await deleteDeviceRecord(record.account).catch(() => {}); + lastRegisterTry.delete(record.account); + return; + } + // Signed out here with nothing kept: the row has nothing left to look after. + if (answer === 'clear') { + const known = useAccountStore.getState().accounts.some((entry) => entry.id === record.account); + if (!known && !useOfflineStore.getState().enabled.includes(record.account)) await dropRows(record.account); + } +} + +/** + * The cookie slot decides whose row the instance makes. A slot that has moved on + * to another account since would leave this account's mail on someone else's row, + * where a wipe of this account never finds it. A bare name is the same person as a + * whole address: a token sign-in can claim either. + */ +function ownsRow(row: RegisteredRow, account: AccountEntry): boolean { + if (serverKey(row.server) !== serverKey(account.serverUrl)) return false; + const user = row.user.toLowerCase(); + return [account.username, account.email].some((candidate) => { + const name = candidate?.toLowerCase(); + if (!name) return false; + if (name === user) return true; + return (!name.includes('@') || !user.includes('@')) && name.split('@')[0] === user.split('@')[0]; + }); +} + +async function registerAccount(account: AccountEntry): Promise { + const register = async () => { + // Another window may have got here while this one waited. + if (await getDeviceRecord(account.id)) return; + const row = await registerWithInstance(account.cookieSlot ?? 0, AbortSignal.timeout(REPORT_BUDGET_MS * 3)); + if (!row) return; + if (ownsRow(row, account) && useAccountStore.getState().getAccountById(account.id)) { + await putDeviceRecord({ account: account.id, id: row.id, secret: row.secret, createdAt: Date.now() }); + return; + } + queueDrop(row); + await flushConfirmations(); + }; + if (typeof navigator !== 'undefined' && navigator.locks) await navigator.locks.request('bulwark-device-register', register); + else await register(); +} + +/** Each row that's due, and a row for each account that needs one. `force`: every row, now. One at a time. */ +export function checkInDue(force = false, now = Date.now()): Promise { + if (checkInFlight) return checkInFlight; + checkInFlight = runCheckIns(force, now).finally(() => { checkInFlight = null; }); + return checkInFlight; +} + +async function runCheckIns(force: boolean, now: number): Promise { + if (!hasInstance()) return; + if (typeof navigator !== 'undefined' && navigator.onLine === false) return; + void flushConfirmations(); + const accounts = useAccountStore.getState().accounts; + const wanted = accounts.filter(wantsRow); + // No account with a reason and no database to look in: don't make one by looking. + const records = await listDeviceRecords(wanted.length === 0).catch(() => [] as DeviceRecord[]); + await Promise.all([ + ...records + .filter((record) => force || now - (lastCheckIn.get(record.account) ?? 0) >= CHECK_IN_EVERY_MS) + .map((record) => checkInRecord(record, now)), + ...wanted + .filter((account) => !records.some((record) => record.account === account.id)) + .filter((account) => force || now - (lastRegisterTry.get(account.id) ?? 0) >= REGISTER_RETRY_MS) + .map((account) => { + lastRegisterTry.set(account.id, now); + return registerAccount(account).catch(() => {}); + }), + ]); +} + +/** A wipe cut short by a crash or a closed window: finished before anything else. No network. */ +export async function finishInterruptedWipes(): Promise { + const pending = readPendingWipes(); + for (const [account, reason] of Object.entries(pending)) { + if (account === WHOLE_DEVICE) await wipeWholeDevice(reason); + else await wipeAccountFromDevice(account, reason); + } + await retryBlockedDatabases(); +} + +// ---- A refused login ---- + +function policyKnown(): boolean { + const { loaded, partial } = usePolicyStore.getState(); + return loaded && !partial; +} + +/** A number, for the sign-in page. Unknown stays unknown, and is asked again at the next look. */ +async function countRefused(account: string): Promise { + if (!(account in readRefusedLogins())) return; + registerOfflineHooks(); + setRefusedCount(account, await unsentForWipe(account)); +} + +/** + * The login was refused, and the auth store is signing it out and hiding the data. + * It goes now only if the organisation asked for that, and only for a refusal that + * is one for certain: a single bad answer at a start can be a server in trouble. + */ +async function heardRefusal(account: string, certain: boolean): Promise { + if (certain && policyKnown() && devicesForAccount(account).wipeOnRefusedLogin) { + await wipeAccountFromDevice(account, 'rejected'); + return; + } + await countRefused(account); +} + +/** What the sign-in page reads: signed in again, the wait is over. A count that couldn't be had, and a policy that's new. */ +export async function sweepRefusedLogins(): Promise { + for (const [account, wait] of Object.entries(readRefusedLogins())) { + if (!isEvicted(account)) { + dropRefusedLogin(account); + continue; + } + // Nobody's signed in to it, so no groups to go by: what applies to everyone reaches it. + if (wait.certain && policyKnown() && devicesForNoOne().wipeOnRefusedLogin) { + await wipeAccountFromDevice(account, 'rejected'); + continue; + } + if (wait.unsent === null) await countRefused(account); + } +} + +let registered = false; + +/** The hooks every wipe needs, once per page. */ +export function registerDeviceWipe(): void { + if (registered) return; + registered = true; + onCredentialsRejected((account, certain) => { + void heardRefusal(account, certain); + }); + onBeforeSignOut(async (accountId, { keepLocalStore }) => { + if (!keepLocalStore) await dropRows(accountId); + }); + onAccountRemoved((accountId, reason) => { + if (reason === 'explicit') void dropRows(accountId); + }); +} + +/** + * What a start waits for, before any local data is shown: a wipe that was cut + * short, without limit, and the instance's answer, up to the budget. An answer + * that comes later still wipes, from wherever the page has got to. + */ +export async function runDeviceGate(): Promise { + registerDeviceWipe(); + await finishInterruptedWipes(); + await sweepRefusedLogins(); + const checks = checkInDue(true); + await Promise.race([ + checks.catch(() => {}), + new Promise((resolve) => setTimeout(resolve, BOOT_CHECK_IN_BUDGET_MS)), + ]); +} + +/** + * Another window is wiping. An account this one isn't showing leaves its lists at + * once and in place, or the next write of the account registry here brings it back. + * One it's showing: its mail is in this window's memory, so it reloads once the wipe + * is done. Another account's unsaved text isn't touched. + */ +function heardFromOtherWindow(account: string | null, phase: WipePhase): void { + const auth = useAuthStore.getState(); + if (account === null || account === auth.activeAccountId) { + if (phase === 'end') window.location.reload(); + return; + } + if (useAccountStore.getState().getAccountById(account)) auth.removeAccount(account, 'wiped'); + const { enabled } = useOfflineStore.getState(); + if (enabled.includes(account)) useOfflineStore.setState({ enabled: enabled.filter((id) => id !== account) }); +} + +function listenToOtherWindows(): () => void { + const channel = otherWindows(); + if (!channel) return () => {}; + channel.onmessage = (event: MessageEvent<{ account: string | null; phase?: WipePhase }>) => { + if (event.data?.account === undefined) return; + heardFromOtherWindow(event.data.account, event.data.phase ?? 'end'); + }; + return () => { channel.onmessage = null; }; +} + +/** For the host component: all of it, for as long as the page runs. */ +export function startDeviceWipe(): () => void { + registerDeviceWipe(); + if (isPopoutWindow()) return listenToOtherWindows(); + const stopOthers = listenToOtherWindows(); + void (async () => { + await finishInterruptedWipes(); + await sweepRefusedLogins(); + await checkInDue(); + })().catch(() => {}); + const tick = setInterval(() => { + void checkInDue().catch(() => {}); + void sweepRefusedLogins().catch(() => {}); + }, TICK_MS); + const onOnline = () => { void checkInDue(true).catch(() => {}); }; + window.addEventListener('online', onOnline); + const stopEnabled = useOfflineStore.subscribe((state, prev) => { + if (state.enabled === prev.enabled) return; + for (const account of state.enabled) lastRegisterTry.delete(account); + void checkInDue().catch(() => {}); + }); + const stopPolicy = usePolicyStore.subscribe((state, prev) => { + if (state.rawPolicy !== prev.rawPolicy || state.partial !== prev.partial) void sweepRefusedLogins().catch(() => {}); + }); + return () => { + stopOthers(); + stopEnabled(); + stopPolicy(); + clearInterval(tick); + window.removeEventListener('online', onOnline); + }; +} diff --git a/lib/offline/evicted-secrets.ts b/lib/offline/evicted-secrets.ts new file mode 100644 index 000000000..490992645 --- /dev/null +++ b/lib/offline/evicted-secrets.ts @@ -0,0 +1,23 @@ +/** + * Plugin secrets of an account the app dropped, in the desktop app, where they sit + * on this device's own server. The account has no cookie left to ask with, so the + * route takes who it was, from what was written down at the time. On an instance + * they aren't the device's to remove, and nothing is written down. + */ + +import { apiFetch } from '@/lib/browser-navigation'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { dropEvictedOwner, readEvictedOwners } from './state'; + +const BUDGET_MS = 3_000; + +/** `account` null: everyone's. */ +export async function forgetEvictedSecrets(account: string | null): Promise { + if (!IS_DESKTOP_BUILD) return; + const owners = Object.entries(readEvictedOwners()).filter(([id]) => account === null || id === account); + await Promise.all(owners.map(async ([id, owner]) => { + const query = new URLSearchParams({ all: 'true', username: owner.username, server: owner.serverUrl }); + const response = await apiFetch(`/api/plugin-secrets?${query}`, { method: 'DELETE', signal: AbortSignal.timeout(BUDGET_MS) }).catch(() => null); + if (response?.ok) dropEvictedOwner(id); + })); +} diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index e473ccc0f..07837e0d4 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -11,15 +11,15 @@ * - Changes and sends made offline wait in the store's outbox and go out in * order once the server answers again and the organisation's policy was * fetched afresh (flush.ts). - * - Everything is wiped at a full sign-out, at the session limits and when the - * server really refuses the login (`invalid_grant`), by - * lib/sign-out-cleanup.ts; for one account when it signs out, is removed or - * switches offline mode off, and when the organisation forbids it (checked on - * every policy fetch). An account the app drops by itself because it couldn't - * confirm the login (an outage, an unknown refusal, a session cookie that ran - * out) keeps its data for 30 days or the next sign-in, hidden from everyone - * else. Unsent messages a sign-out deletes without the user's word are named - * (lost.ts). + * - Everything is wiped at a full sign-out, by lib/sign-out-cleanup.ts; for one + * account when it signs out, is removed or switches offline mode off, and when + * the organisation forbids it (checked on every policy fetch). An account the + * app drops by itself keeps its data for 30 days or the next sign-in, hidden from + * everyone else: when it couldn't confirm the login (an outage, an unknown + * refusal, a session cookie that ran out), at the organisation's session limit, + * and when the server refuses the login (`invalid_grant`, a lasting 401), unless + * the organisation asks for deletion at once (lib/offline/device-wipe.ts). Unsent + * messages a sign-out deletes without the user's word are named (lost.ts). */ import { IS_LITE } from '@/lib/lite'; @@ -92,6 +92,7 @@ import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from ' import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy'; import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; +import { forgetEvictedSecrets } from './evicted-secrets'; export type OfflineNotice = | { kind: 'queued'; subject: string } @@ -1029,6 +1030,31 @@ export async function unsentCount(scope: string, waitMs = UNSENT_READ_MS): Promi return Promise.race([read, new Promise((resolve) => setTimeout(() => resolve(known), waitMs))]); } +/** How long a wipe waits for the count. A wipe's report is the only record of what it deleted. */ +export const UNSENT_FOR_WIPE_MS = 10_000; + +async function unsentOfAccount(account: string, waitMs: number): Promise { + const shown = useOfflineStore.getState(); + // Reading an account's store would create it if it never had one. + if (!shown.enabled.includes(account)) return [...shown.outbox, ...shown.localDrafts].filter((row) => row.account === account).length; + let timer: ReturnType | undefined; + const read = Promise.all([readQueue(account), readLocalDrafts(account)]) + .then(([{ failed, outbox: entries }, drafts]) => (failed.length > 0 ? null : entries.length + drafts.length), () => null); + const answer = await Promise.race([read, new Promise((resolve) => { timer = setTimeout(() => resolve(null), waitMs); })]); + clearTimeout(timer); + return answer; +} + +/** + * How many unsent messages a wipe of `scope` deletes, one account or 'all', hidden + * accounts included. Null when the store can't say: that isn't the same as none. + */ +export async function unsentForWipe(scope: string, waitMs = UNSENT_FOR_WIPE_MS): Promise { + const accounts = scope === 'all' ? useOfflineStore.getState().enabled : [scope]; + const counts = await Promise.all(accounts.map((account) => unsentOfAccount(account, waitMs))); + return counts.includes(null) ? null : (counts as number[]).reduce((sum, count) => sum + count, 0); +} + /** Names the unsent messages a wipe of `scope` deletes, unless the user agreed. */ async function nameLost(scope: string, stillSignedIn: boolean): Promise { if (named.has(scope) || named.has('all')) return; @@ -1051,7 +1077,10 @@ export async function wipeAccount(account: string): Promise { if (timer) clearTimeout(timer); syncTimers.delete(account); // Waited for a sign-in that never came: whoever is here now mustn't read its subjects. - if (isEvicted(account)) named.add(account); + if (isEvicted(account)) { + named.add(account); + void forgetEvictedSecrets(account).catch(() => {}); + } const before = useOfflineStore.getState(); if (before.enabled.includes(account) || before.outbox.some((row) => row.account === account) || before.localDrafts.some((row) => row.account === account)) { await nameLost(account, useAuthStore.getState().isAuthenticated); @@ -1352,7 +1381,7 @@ export function startOfflineMode(): () => void { * that left the registry on purpose before this ran (a login without "remember * me" ends at a reload, before the host mounts) goes now, naming lost mail. */ -async function sweepAccountsGone(now = Date.now()): Promise { +export async function sweepAccountsGone(now = Date.now()): Promise { const evicted = readEvicted(); const accounts = useAccountStore.getState().accounts; for (const account of useOfflineStore.getState().enabled) { diff --git a/lib/offline/state.ts b/lib/offline/state.ts index f85726a57..d78392c42 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -254,6 +254,29 @@ export function clearEvicted(account: string): void { writeEvicted(evicted); } +/** localStorage: who a dropped account was, only in the desktop app: its plugin secrets are on this device's server, under this. */ +export const OFFLINE_EVICTED_OWNERS_KEY = 'bulwark-offline-evicted-owners'; + +export interface EvictedOwner { + username: string; + serverUrl: string; +} + +export function readEvictedOwners(): Record { + return readRecord(OFFLINE_EVICTED_OWNERS_KEY); +} + +export function rememberEvictedOwner(account: string, owner: EvictedOwner): void { + writeRecord(OFFLINE_EVICTED_OWNERS_KEY, { ...readEvictedOwners(), [account]: { username: owner.username, serverUrl: owner.serverUrl } }); +} + +export function dropEvictedOwner(account: string): void { + const owners = readEvictedOwners(); + if (!(account in owners)) return; + delete owners[account]; + writeRecord(OFFLINE_EVICTED_OWNERS_KEY, owners); +} + export function isEvicted(account: string): boolean { return account in readEvicted(); } diff --git a/lib/offline/wipe-registry.ts b/lib/offline/wipe-registry.ts new file mode 100644 index 000000000..2b9d8f393 --- /dev/null +++ b/lib/offline/wipe-registry.ts @@ -0,0 +1,39 @@ +/** + * Where a feature with data of its own on this device signs up for wipes: + * edit-in-place's opened files, for one. Free of the stores, so anything can + * import it. Registering is all a feature has to do. + * + * A cleaner runs on every remote wipe, policy wipe, wipe from settings and + * refused login, before the account signs out. `account` is an account id, or + * `null` when everything on this device goes. A cleaner should finish quickly + * and may not ask the user anything. Bridge calls are fine, network isn't: a + * wipe often runs because the server said no. + */ + +export type WipeCleaner = (account: string | null) => void | Promise; + +const cleaners = new Set(); + +export function onDeviceWipe(cleaner: WipeCleaner): () => void { + cleaners.add(cleaner); + return () => cleaners.delete(cleaner); +} + +/** How long a wipe waits for one cleaner. What's on screen waits with it. */ +const CLEANER_BUDGET_MS = 10_000; + +export async function runWipeCleaners(account: string | null): Promise { + await Promise.all([...cleaners].map(async (cleaner) => { + let timer: ReturnType | undefined; + try { + await Promise.race([ + cleaner(account), + new Promise((resolve) => { timer = setTimeout(resolve, CLEANER_BUDGET_MS); }), + ]); + } catch { + // One feature failing must not keep the others' data. + } finally { + if (timer) clearTimeout(timer); + } + })); +} diff --git a/lib/plugin-secrets/vault.ts b/lib/plugin-secrets/vault.ts index ce1e4d8de..c7c74329c 100644 --- a/lib/plugin-secrets/vault.ts +++ b/lib/plugin-secrets/vault.ts @@ -217,6 +217,11 @@ export function setUserCredentialSecret( }); } +/** Everything this user connected, for every plugin. */ +export async function deleteAllUserCredentials(user: VaultUser): Promise { + await collection().delete(userKey(user)); +} + /** Remove one credential, or all of a plugin's when credentialId is omitted. */ export function deleteUserCredentials(user: VaultUser, pluginId: string, credentialId?: string): Promise { return modify(user, (data) => { diff --git a/lib/popout/rescued-drafts.ts b/lib/popout/rescued-drafts.ts index 98a5f7c4d..5e39a2672 100644 --- a/lib/popout/rescued-drafts.ts +++ b/lib/popout/rescued-drafts.ts @@ -46,6 +46,12 @@ export function takeRescuedDraft(): ComposerDraftData | null { return first; } +/** A wipe: the text of a message nobody could save goes too. */ +export function clearRescuedDrafts(): void { + memory = []; + write([]); +} + /** Calls `listener` when a draft comes in. Returns the unsubscribe. */ export function onRescuedDraft(listener: () => void): () => void { listeners.add(listener); diff --git a/lib/shared-store/__tests__/import-data.test.ts b/lib/shared-store/__tests__/import-data.test.ts index 80e1c589c..9335c4b9d 100644 --- a/lib/shared-store/__tests__/import-data.test.ts +++ b/lib/shared-store/__tests__/import-data.test.ts @@ -14,6 +14,7 @@ import { VAULT_COLLECTION } from '@/lib/plugin-secrets/vault'; import { ARCHIVE_COLLECTION } from '@/lib/account-archive-storage'; import { TELEMETRY_COLLECTION } from '@/lib/telemetry/state'; import { VERSION_CHECK_COLLECTION } from '@/lib/version-check/state'; +import { DEVICES_COLLECTION } from '@/lib/devices/registry'; // An existing single instance's settings, plugin credentials, archives, // telemetry and update-check state go into an empty shared store once, as @@ -43,9 +44,12 @@ beforeEach(() => { vi.stubEnv('PLUGIN_SECRETS_DIR', path.join(data, 'plugin-secrets')); vi.stubEnv('TELEMETRY_DATA_DIR', path.join(data, 'telemetry')); vi.stubEnv('VERSION_CHECK_DATA_DIR', path.join(data, 'version-check')); + vi.stubEnv('ADMIN_STATE_DIR', path.join(data, 'admin-state')); for (const dir of ['settings/account-archives', 'plugin-secrets', 'telemetry', 'version-check']) { mkdirSync(path.join(data, dir), { recursive: true }); } + mkdirSync(path.join(data, 'admin-state', 'devices', hash('f')), { recursive: true }); + writeFileSync(path.join(data, 'admin-state', 'devices', hash('f'), `${'0'.repeat(32)}.json`), '{"user":"a"}'); writeFileSync(path.join(data, 'settings', `${hash('a')}.enc`), Buffer.from([1, 2, 3, 4])); writeFileSync(path.join(data, 'settings', `${hash('b')}.enc`), Buffer.from([5, 6])); writeFileSync(path.join(data, 'settings', `${hash('c')}.enc.tmp`), 'half written'); @@ -78,10 +82,10 @@ describe('importing part data', () => { const before = snapshot(data); expect(await runAll()).toEqual({ - settings: 2, 'plugin-secrets': 1, 'account-archives': 1, telemetry: 3, 'version-check': 1, + settings: 2, 'plugin-secrets': 1, 'account-archives': 1, telemetry: 3, 'version-check': 1, devices: 1, }); expect(await runAll()).toEqual({ - settings: 0, 'plugin-secrets': 0, 'account-archives': 0, telemetry: 0, 'version-check': 0, + settings: 0, 'plugin-secrets': 0, 'account-archives': 0, telemetry: 0, 'version-check': 0, devices: 0, }); expect(snapshot(data)).toEqual(before); @@ -92,6 +96,7 @@ describe('importing part data', () => { expect((await store.collection('account-archives', ARCHIVE_COLLECTION).get(hash('d')))?.data.toString()).toBe('{"version":1}'); expect((await store.collection('telemetry', TELEMETRY_COLLECTION).list()).sort()).toEqual(['.telemetry-id', 'logins.json', 'state.json']); expect((await store.collection('version-check', VERSION_CHECK_COLLECTION).get('state.json'))?.data.toString()).toBe('{"status":null}'); + expect((await store.collection('devices', DEVICES_COLLECTION).get(`${hash('f')}/${'0'.repeat(32)}`))?.data.toString()).toBe('{"user":"a"}'); }); it('keeps what the store already holds', async () => { diff --git a/lib/shared-store/export.ts b/lib/shared-store/export.ts index d14775c0f..9a7f2297f 100644 --- a/lib/shared-store/export.ts +++ b/lib/shared-store/export.ts @@ -11,6 +11,7 @@ import { VAULT_COLLECTION } from '@/lib/plugin-secrets/vault'; import { ARCHIVE_COLLECTION } from '@/lib/account-archive-storage'; import { TELEMETRY_COLLECTION } from '@/lib/telemetry/state'; import { VERSION_CHECK_COLLECTION } from '@/lib/version-check/state'; +import { DEVICES_COLLECTION } from '@/lib/devices/registry'; import { getStore } from './index'; import { exportSteps as extExportSteps } from './import/ext'; import { localCollection, localLog } from './local'; @@ -93,6 +94,7 @@ export const EXPORT_STEPS: ExportStep[] = [ exportCollection('account-archives', ARCHIVE_COLLECTION), exportCollection('telemetry', TELEMETRY_COLLECTION), exportCollection('version-check', VERSION_CHECK_COLLECTION), + exportCollection('devices', DEVICES_COLLECTION), ]; export async function exportSharedData(steps: ExportStep[] = EXPORT_STEPS): Promise> { diff --git a/lib/shared-store/import/data.ts b/lib/shared-store/import/data.ts index 9af9a1030..592cfe671 100644 --- a/lib/shared-store/import/data.ts +++ b/lib/shared-store/import/data.ts @@ -7,6 +7,7 @@ import { VAULT_COLLECTION } from '@/lib/plugin-secrets/vault'; import { ARCHIVE_COLLECTION } from '@/lib/account-archive-storage'; import { TELEMETRY_COLLECTION } from '@/lib/telemetry/state'; import { VERSION_CHECK_COLLECTION } from '@/lib/version-check/state'; +import { DEVICES_COLLECTION } from '@/lib/devices/registry'; /** * Part `data` of the shared-store import (#1066): every document of these @@ -39,4 +40,5 @@ export const steps: ImportStep[] = [ // state.json, logins.json and the instance id .telemetry-id copyCollection('telemetry', TELEMETRY_COLLECTION), copyCollection('version-check', VERSION_CHECK_COLLECTION), + copyCollection('devices', DEVICES_COLLECTION), ]; diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 68f52791c..19a35c88e 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -38,13 +38,17 @@ const SIGNED_OUT_STORAGE_KEYS = [ 'bulwark-offline-accounts', // Which accounts' data waits for a new sign-in, and until when. 'bulwark-offline-evicted', + 'bulwark-offline-evicted-owners', 'bulwark-store-opened', // What each account chose to keep, and what's known about its store. 'bulwark-offline-scope', 'bulwark-offline-health', ]; -const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted', 'bulwark-store-opened', 'bulwark-offline-scope', 'bulwark-offline-health']); +const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted', 'bulwark-offline-evicted-owners', 'bulwark-store-opened', 'bulwark-offline-scope', 'bulwark-offline-health']); + +/** What's the accounts' but isn't the local store's: a wipe of one account that leaves others signed in takes these too. */ +export const ACCOUNT_STORAGE_KEYS = SIGNED_OUT_STORAGE_KEYS.filter((key) => !LOCAL_STORE_KEYS.has(key)); const SIGNED_OUT_SIGNAL = 'bulwark:signed-out'; export { SIGNED_OUT_SIGNAL as SIGNED_OUT_KEY }; @@ -131,7 +135,7 @@ export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void { // it was encrypted with, and the app shell the service worker kept. Also the // database of older versions, in case it wasn't moved yet. if (!options.keepLocalStore) { - void purgeStoreFiles(); + void purgeStoreFiles().catch(() => {}); try { window.indexedDB?.deleteDatabase('bulwark-offline'); } catch { diff --git a/lib/store/client/keys.ts b/lib/store/client/keys.ts index 059beb916..7a88e8240 100644 --- a/lib/store/client/keys.ts +++ b/lib/store/client/keys.ts @@ -170,6 +170,55 @@ export async function forgetAccountKey(account: string): Promise { await withDb((db) => promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).delete(account)).then(() => undefined)); } +/** + * This browser profile's row at the instance, for one account: its id, and the + * secret that lets a device answer for that row without a session. In this + * database and nowhere else, because a start has to read it before any store + * opens, and a copy in the store could never outlive the key that opens it. A + * cleared database means a new device at the next registration. + */ +export interface DeviceRecord { + account: string; + id: string; + secret: string; + createdAt: number; +} + +const DEVICE_RECORD_PREFIX = 'device:'; +// ':' + 1, so the range holds exactly the prefixed keys. +const DEVICE_RECORD_END = 'device;'; + +export async function getDeviceRecord(account: string): Promise { + return withDb(async (db) => { + const found = await promisify(db.transaction(KEYS, 'readonly').objectStore(KEYS).get(DEVICE_RECORD_PREFIX + account)) as { record: DeviceRecord } | undefined; + return found?.record ?? null; + }); +} + +export async function putDeviceRecord(record: DeviceRecord): Promise { + await withDb((db) => promisify(db.transaction(KEYS, 'readwrite').objectStore(KEYS).put({ id: DEVICE_RECORD_PREFIX + record.account, record })).then(() => undefined)); +} + +export async function deleteDeviceRecord(account: string): Promise { + if (!(await keyDatabaseExists())) return; + await withDb((db) => promisify(db.transaction(KEYS, 'readwrite').objectStore(KEYS).delete(DEVICE_RECORD_PREFIX + account)).then(() => undefined)); +} + +/** Opening the database makes it, so a browser that can say it isn't there doesn't open it. */ +async function keyDatabaseExists(): Promise { + if (typeof indexedDB.databases !== 'function') return true; + return (await indexedDB.databases()).some((database) => database.name === DB_NAME); +} + +export async function listDeviceRecords(onlyIfThere = true): Promise { + if (onlyIfThere && !(await keyDatabaseExists())) return []; + return withDb(async (db) => { + const range = IDBKeyRange.bound(DEVICE_RECORD_PREFIX, DEVICE_RECORD_END, false, true); + const rows = await promisify(db.transaction(KEYS, 'readonly').objectStore(KEYS).getAll(range)) as Array<{ record: DeviceRecord }>; + return rows.map((row) => row.record); + }); +} + export class StoreKeyLayerMissingError extends Error { constructor(readonly layer: string) { super(`The key needs the ${layer} layer`); diff --git a/locales/ar/common.json b/locales/ar/common.json index 6c467aa5c..94b392c9e 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5763,6 +5763,28 @@ "store_private": "لا يمكن للنوافذ الخاصة الاحتفاظ بالبريد للاستخدام دون اتصال.", "server_not_answering": "خادم البريد لا يستجيب. حاول مرة أخرى بعد قليل." }, + "offlineWipe": { + "note": "أزالت مؤسستك بيانات هذا الحساب من هذا الجهاز. سجّل الدخول للبدء من جديد.", + "note_unsent": "أزالت مؤسستك بيانات هذا الحساب من هذا الجهاز. الرسائل غير المرسلة المحذوفة: {count}.", + "settings_title": "إزالة كل شيء من هذا الجهاز", + "settings_description": "عندما تعطي هذا الحاسوب لشخص آخر أو تتوقف عن استخدامه. يبقى بريدك على الخادم.", + "settings_button": "إزالة كل شيء…", + "settings_wiping": "جارٍ الإزالة…", + "confirm_title": "هل تريد إزالة كل شيء من هذا الجهاز؟", + "confirm_message": "سيحذف هذا ما يحتفظ به التطبيق على هذا الجهاز، لكل الحسابات هنا. لن يتأثر بريدك على الخادم.", + "confirm_mail": "البريد والتقاويم وجهات الاتصال والملفات المحفوظة هنا", + "confirm_unsent": "الرسائل غير المرسلة المنتظرة هنا: {count}. ستُفقد.", + "confirm_keys": "مفاتيح S/MIME وOpenPGP. ستحتاج إلى نسختك الاحتياطية لاستخدامها مجددًا.", + "confirm_plugins": "البيانات التي خزّنتها الإضافات هنا", + "confirm_files": "المرفقات المفتوحة للتحرير والملفات المنتظرة للإرفاق", + "confirm_accounts": "يتم تسجيل الخروج من كل الحسابات", + "confirm_button": "إزالة كل شيء", + "note_unknown": "أزالت مؤسستك بيانات هذا الحساب من هذا الجهاز. ربما حُذفت أيضًا رسائل لم تُرسل.", + "refused_waiting": "رسائل لم تُرسل تنتظر كلمة مرورك الجديدة: {count}. تبقى حتى {date}.", + "refused_waiting_unknown": "ربما توجد رسائل لم تُرسل تنتظر كلمة مرورك الجديدة. تبقى حتى {date}.", + "confirm_unsent_unknown": "ربما توجد هنا رسائل لم تُرسل. ستُفقد.", + "confirm_templates": "قوالب البريد الإلكتروني المحفوظة على هذا الجهاز" + }, "offlineStorage": { "keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة على هذا الجهاز. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.", "window_label": "إلى أي مدة سابقة يُحتفظ بالبريد", diff --git a/locales/ca/common.json b/locales/ca/common.json index f793ac547..afb33a7f9 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5763,6 +5763,28 @@ "store_private": "Les finestres privades no poden desar correu per fer-lo servir sense connexió.", "server_not_answering": "El servidor de correu no respon. Torneu-ho a provar d'aquí a un moment." }, + "offlineWipe": { + "note": "La teva organització ha tret les dades d'aquest compte d'aquest dispositiu. Inicia la sessió per tornar a començar.", + "note_unsent": "La teva organització ha tret les dades d'aquest compte d'aquest dispositiu. Missatges sense enviar eliminats: {count}.", + "settings_title": "Treu-ho tot d'aquest dispositiu", + "settings_description": "Per quan regalis aquest ordinador o el deixis d'utilitzar. El teu correu al servidor es manté.", + "settings_button": "Treu-ho tot…", + "settings_wiping": "S'està traient…", + "confirm_title": "Vols treure-ho tot d'aquest dispositiu?", + "confirm_message": "Això elimina el que aquesta aplicació guarda en aquest dispositiu, per a tots els comptes d'aquí. El teu correu al servidor no es toca.", + "confirm_mail": "Correu, calendaris, contactes i fitxers guardats aquí", + "confirm_unsent": "Missatges sense enviar en espera aquí: {count}. Es perdran.", + "confirm_keys": "Claus S/MIME i OpenPGP. Necessitaràs la teva pròpia còpia de seguretat per tornar-les a utilitzar.", + "confirm_plugins": "Dades que els connectors han guardat aquí", + "confirm_files": "Adjunts oberts per editar i fitxers pendents d'adjuntar", + "confirm_accounts": "Es tanca la sessió de tots els comptes", + "confirm_button": "Treu-ho tot", + "note_unknown": "La teva organització ha eliminat les dades d'aquest compte d'aquest dispositiu. És possible que també s'hagin suprimit missatges sense enviar.", + "refused_waiting": "Missatges sense enviar que esperen la teva contrasenya nova: {count}. Es conserven fins al {date}.", + "refused_waiting_unknown": "És possible que hi hagi missatges sense enviar esperant la teva contrasenya nova. Es conserven fins al {date}.", + "confirm_unsent_unknown": "És possible que hi hagi missatges sense enviar esperant aquí. Es perdran.", + "confirm_templates": "Plantilles de correu desades en aquest dispositiu" + }, "offlineStorage": { "keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest dispositiu. Sense connexió podeu llegir, marcar i moure correu, i el que escriviu espera a la safata de sortida. En tancar la sessió s'esborra tot.", "window_label": "Fins a quan enrere desar", diff --git a/locales/cs/common.json b/locales/cs/common.json index a873b79b5..fb07e25a0 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5763,6 +5763,28 @@ "store_private": "Soukromá okna nemohou uchovávat poštu pro použití offline.", "server_not_answering": "Poštovní server neodpovídá. Zkuste to za chvíli znovu." }, + "offlineWipe": { + "note": "Vaše organizace odebrala data tohoto účtu z tohoto zařízení. Přihlaste se a začněte znovu.", + "note_unsent": "Vaše organizace odebrala data tohoto účtu z tohoto zařízení. Smazané neodeslané zprávy: {count}.", + "settings_title": "Odebrat vše z tohoto zařízení", + "settings_description": "Pro případ, že tento počítač předáte dál nebo ho přestanete používat. Vaše pošta na serveru zůstane.", + "settings_button": "Odebrat vše…", + "settings_wiping": "Odebírání…", + "confirm_title": "Odebrat vše z tohoto zařízení?", + "confirm_message": "Tím se smaže to, co tato aplikace uchovává na tomto zařízení, pro všechny zdejší účty. Vaší pošty na serveru se to nedotkne.", + "confirm_mail": "Pošta, kalendáře, kontakty a soubory uložené zde", + "confirm_unsent": "Neodeslané zprávy čekající zde: {count}. Budou ztraceny.", + "confirm_keys": "Klíče S/MIME a OpenPGP. Abyste je mohli znovu použít, budete potřebovat vlastní zálohu.", + "confirm_plugins": "Data, která sem uložily zásuvné moduly", + "confirm_files": "Přílohy otevřené k úpravám a soubory čekající na připojení", + "confirm_accounts": "Všechny účty budou odhlášeny", + "confirm_button": "Odebrat vše", + "note_unknown": "Vaše organizace odstranila data tohoto účtu z tohoto zařízení. Možná byly smazány i neodeslané zprávy.", + "refused_waiting": "Neodeslané zprávy čekající na nové heslo: {count}. Zůstanou do {date}.", + "refused_waiting_unknown": "Na nové heslo možná čekají neodeslané zprávy. Zůstanou do {date}.", + "confirm_unsent_unknown": "Možná tu čekají neodeslané zprávy. Budou ztraceny.", + "confirm_templates": "Šablony e-mailů uložené v tomto zařízení" + }, "offlineStorage": { "keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto zařízení. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.", "window_label": "Jak dlouho do minulosti uchovávat", diff --git a/locales/da/common.json b/locales/da/common.json index 58c79b036..554bbdbbe 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5763,6 +5763,28 @@ "store_private": "Private vinduer kan ikke gemme mail til brug offline.", "server_not_answering": "Mailserveren svarer ikke. Prøv igen om lidt." }, + "offlineWipe": { + "note": "Din organisation har fjernet denne kontos data fra denne enhed. Log ind for at starte forfra.", + "note_unsent": "Din organisation har fjernet denne kontos data fra denne enhed. Slettede ikke-sendte beskeder: {count}.", + "settings_title": "Fjern alt fra denne enhed", + "settings_description": "Til når du giver computeren væk eller holder op med at bruge den. Din post på serveren bliver.", + "settings_button": "Fjern alt…", + "settings_wiping": "Fjerner…", + "confirm_title": "Fjern alt fra denne enhed?", + "confirm_message": "Det sletter det, denne app gemmer på denne enhed, for alle konti her. Din post på serveren røres ikke.", + "confirm_mail": "Post, kalendere, kontakter og filer gemt her", + "confirm_unsent": "Ikke-sendte beskeder, der venter her: {count}. De går tabt.", + "confirm_keys": "S/MIME- og OpenPGP-nøgler. Du skal bruge din egen sikkerhedskopi for at bruge dem igen.", + "confirm_plugins": "Data, som plugins har gemt her", + "confirm_files": "Vedhæftede filer åbnet til redigering og filer, der venter på at blive vedhæftet", + "confirm_accounts": "Alle konti logges ud", + "confirm_button": "Fjern alt", + "note_unknown": "Din organisation har fjernet denne kontos data fra denne enhed. Usendte beskeder kan også være slettet.", + "refused_waiting": "Usendte beskeder, der venter på din nye adgangskode: {count}. De bliver til {date}.", + "refused_waiting_unknown": "Usendte beskeder venter måske på din nye adgangskode. De bliver til {date}.", + "confirm_unsent_unknown": "Der venter måske usendte beskeder her. De går tabt.", + "confirm_templates": "E-mailskabeloner gemt på denne enhed" + }, "offlineStorage": { "keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret på denne enhed. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.", "window_label": "Hvor langt tilbage der beholdes", diff --git a/locales/de/common.json b/locales/de/common.json index 0388c8994..87c4fa0d7 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5763,6 +5763,28 @@ "store_private": "Private Fenster können keine E-Mails für die Offline-Nutzung behalten.", "server_not_answering": "Der Mailserver antwortet nicht. Versuchen Sie es gleich noch einmal." }, + "offlineWipe": { + "note": "Ihre Organisation hat die Daten dieses Kontos von diesem Gerät entfernt. Melden Sie sich an, um neu zu beginnen.", + "note_unsent": "Ihre Organisation hat die Daten dieses Kontos von diesem Gerät entfernt. Gelöschte ungesendete Nachrichten: {count}.", + "settings_title": "Alles von diesem Gerät entfernen", + "settings_description": "Für den Fall, dass Sie diesen Computer weitergeben oder nicht mehr nutzen. Ihre E-Mails auf dem Server bleiben erhalten.", + "settings_button": "Alles entfernen…", + "settings_wiping": "Wird entfernt…", + "confirm_title": "Alles von diesem Gerät entfernen?", + "confirm_message": "Damit wird gelöscht, was diese App auf diesem Gerät speichert, für alle Konten hier. Ihre E-Mails auf dem Server bleiben unberührt.", + "confirm_mail": "Hier gespeicherte E-Mails, Kalender, Kontakte und Dateien", + "confirm_unsent": "Hier wartende ungesendete Nachrichten: {count}. Sie gehen verloren.", + "confirm_keys": "S/MIME- und OpenPGP-Schlüssel. Um sie wieder zu nutzen, brauchen Sie Ihre eigene Sicherung.", + "confirm_plugins": "Daten, die Plugins hier gespeichert haben", + "confirm_files": "Zum Bearbeiten geöffnete Anhänge und Dateien, die noch angehängt werden sollen", + "confirm_accounts": "Alle Konten werden abgemeldet", + "confirm_button": "Alles entfernen", + "note_unknown": "Ihre Organisation hat die Daten dieses Kontos von diesem Gerät entfernt. Dabei wurden möglicherweise auch ungesendete Nachrichten gelöscht.", + "refused_waiting": "Auf Ihr neues Passwort warten ungesendete Nachrichten: {count}. Sie bleiben bis zum {date}.", + "refused_waiting_unknown": "Möglicherweise warten ungesendete Nachrichten auf Ihr neues Passwort. Sie bleiben bis zum {date}.", + "confirm_unsent_unknown": "Hier warten möglicherweise ungesendete Nachrichten. Sie gehen verloren.", + "confirm_templates": "Auf diesem Gerät gespeicherte E-Mail-Vorlagen" + }, "offlineStorage": { "keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt auf diesem Gerät. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.", "window_label": "Wie weit zurück behalten", diff --git a/locales/en/common.json b/locales/en/common.json index c7cf59956..fb167d8d3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5763,6 +5763,28 @@ "store_private": "Private windows can't keep mail for offline use.", "server_not_answering": "The mail server isn't answering. Try again in a moment." }, + "offlineWipe": { + "note": "Your organisation removed this account's data from this device. Sign in to start again.", + "note_unsent": "Your organisation removed this account's data from this device. Unsent messages deleted: {count}.", + "settings_title": "Remove everything from this device", + "settings_description": "For when you give this computer away or stop using it. Your mail on the server stays.", + "settings_button": "Remove everything…", + "settings_wiping": "Removing…", + "confirm_title": "Remove everything from this device?", + "confirm_message": "This deletes what this app keeps on this device, for every account here. Your mail on the server isn't touched.", + "confirm_mail": "Mail, calendars, contacts and files kept here", + "confirm_unsent": "Unsent messages waiting here: {count}. They will be lost.", + "confirm_keys": "S/MIME and OpenPGP keys. You need your own backup to use them again.", + "confirm_plugins": "Data that plugins stored here", + "confirm_files": "Attachments opened for editing and files waiting to be attached", + "confirm_accounts": "All accounts are signed out", + "confirm_button": "Remove everything", + "note_unknown": "Your organisation removed this account's data from this device. Unsent messages may have been deleted too.", + "refused_waiting": "{count, plural, one {# unsent message waits} other {# unsent messages wait}} for your new password, until {date}.", + "refused_waiting_unknown": "Unsent messages may be waiting for your new password, until {date}.", + "confirm_unsent_unknown": "Unsent messages may be waiting here. They will be lost.", + "confirm_templates": "Email templates saved on this device" + }, "offlineStorage": { "keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted on this device. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.", "window_label": "How far back to keep", diff --git a/locales/es/common.json b/locales/es/common.json index 35c0af539..21946307b 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5763,6 +5763,28 @@ "store_private": "Las ventanas privadas no pueden guardar correo para usarlo sin conexión.", "server_not_answering": "El servidor de correo no responde. Inténtalo de nuevo en un momento." }, + "offlineWipe": { + "note": "Tu organización ha quitado los datos de esta cuenta de este dispositivo. Inicia sesión para empezar de nuevo.", + "note_unsent": "Tu organización ha quitado los datos de esta cuenta de este dispositivo. Mensajes sin enviar eliminados: {count}.", + "settings_title": "Quitar todo de este dispositivo", + "settings_description": "Para cuando regales este ordenador o dejes de usarlo. Tu correo en el servidor se mantiene.", + "settings_button": "Quitar todo…", + "settings_wiping": "Quitando…", + "confirm_title": "¿Quitar todo de este dispositivo?", + "confirm_message": "Esto elimina lo que esta aplicación guarda en este dispositivo, para todas las cuentas de aquí. Tu correo en el servidor no se toca.", + "confirm_mail": "Correo, calendarios, contactos y archivos guardados aquí", + "confirm_unsent": "Mensajes sin enviar en espera aquí: {count}. Se perderán.", + "confirm_keys": "Claves S/MIME y OpenPGP. Necesitarás tu propia copia de seguridad para volver a usarlas.", + "confirm_plugins": "Datos que los plugins guardaron aquí", + "confirm_files": "Adjuntos abiertos para editar y archivos a la espera de adjuntarse", + "confirm_accounts": "Se cierra la sesión de todas las cuentas", + "confirm_button": "Quitar todo", + "note_unknown": "Tu organización ha eliminado los datos de esta cuenta de este dispositivo. Puede que también se hayan eliminado mensajes sin enviar.", + "refused_waiting": "Mensajes sin enviar que esperan tu nueva contraseña: {count}. Se conservan hasta el {date}.", + "refused_waiting_unknown": "Puede que haya mensajes sin enviar esperando tu nueva contraseña. Se conservan hasta el {date}.", + "confirm_unsent_unknown": "Puede que haya mensajes sin enviar esperando aquí. Se perderán.", + "confirm_templates": "Plantillas de correo guardadas en este dispositivo" + }, "offlineStorage": { "keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este dispositivo. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.", "window_label": "Cuánto tiempo atrás guardar", diff --git a/locales/et/common.json b/locales/et/common.json index d0fc05409..565e84f5e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5763,6 +5763,28 @@ "store_private": "Privaatsed aknad ei saa kirju võrguühenduseta kasutamiseks hoida.", "server_not_answering": "Meiliserver ei vasta. Proovige kohe uuesti." }, + "offlineWipe": { + "note": "Teie organisatsioon eemaldas selle konto andmed sellest seadmest. Alustamiseks logige uuesti sisse.", + "note_unsent": "Teie organisatsioon eemaldas selle konto andmed sellest seadmest. Kustutatud saatmata kirju: {count}.", + "settings_title": "Eemalda sellest seadmest kõik", + "settings_description": "Juhuks, kui annate selle arvuti ära või lõpetate selle kasutamise. Teie kirjad serveris jäävad alles.", + "settings_button": "Eemalda kõik…", + "settings_wiping": "Eemaldamine…", + "confirm_title": "Kas eemaldada sellest seadmest kõik?", + "confirm_message": "See kustutab kõik, mida see rakendus selles seadmes hoiab, kõigi siinsete kontode jaoks. Teie kirju serveris see ei puuduta.", + "confirm_mail": "Siin hoitavad kirjad, kalendrid, kontaktid ja failid", + "confirm_unsent": "Siin ootavaid saatmata kirju: {count}. Need lähevad kaotsi.", + "confirm_keys": "S/MIME- ja OpenPGP-võtmed. Nende uuesti kasutamiseks on vaja teie enda varukoopiat.", + "confirm_plugins": "Pistikprogrammide siia salvestatud andmed", + "confirm_files": "Muutmiseks avatud manused ja lisamist ootavad failid", + "confirm_accounts": "Kõik kontod logitakse välja", + "confirm_button": "Eemalda kõik", + "note_unknown": "Teie organisatsioon eemaldas selle konto andmed sellest seadmest. Võimalik, et kustutati ka saatmata sõnumid.", + "refused_waiting": "Saatmata sõnumid ootavad teie uut parooli: {count}. Need jäävad alles kuni {date}.", + "refused_waiting_unknown": "Saatmata sõnumid võivad oodata teie uut parooli. Need jäävad alles kuni {date}.", + "confirm_unsent_unknown": "Siin võivad oodata saatmata sõnumid. Need lähevad kaduma.", + "confirm_templates": "Selles seadmes salvestatud e-kirjade mallid" + }, "offlineStorage": { "keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles seadmes. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.", "window_label": "Kui kaugele minevikku hoida", diff --git a/locales/fa/common.json b/locales/fa/common.json index 5aef56be6..fb98c6508 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5763,6 +5763,28 @@ "store_private": "پنجره‌های خصوصی نمی‌توانند ایمیل را برای استفادهٔ آفلاین نگه دارند.", "server_not_answering": "سرور ایمیل پاسخ نمی‌دهد. کمی بعد دوباره امتحان کنید." }, + "offlineWipe": { + "note": "سازمان شما داده‌های این حساب را از این دستگاه حذف کرد. برای شروع دوباره وارد شوید.", + "note_unsent": "سازمان شما داده‌های این حساب را از این دستگاه حذف کرد. پیام‌های ارسال‌نشدهٔ حذف‌شده: {count}.", + "settings_title": "حذف همه چیز از این دستگاه", + "settings_description": "برای زمانی که این رایانه را به کسی می‌دهید یا دیگر از آن استفاده نمی‌کنید. نامه‌های شما روی سرور می‌ماند.", + "settings_button": "حذف همه چیز…", + "settings_wiping": "در حال حذف…", + "confirm_title": "همه چیز از این دستگاه حذف شود؟", + "confirm_message": "این کار آنچه این برنامه روی این دستگاه نگه می‌دارد را برای همهٔ حساب‌های اینجا حذف می‌کند. به نامه‌های شما روی سرور دست زده نمی‌شود.", + "confirm_mail": "نامه‌ها، تقویم‌ها، مخاطبان و فایل‌های نگه‌داری‌شده در اینجا", + "confirm_unsent": "پیام‌های ارسال‌نشدهٔ در انتظار در اینجا: {count}. از بین خواهند رفت.", + "confirm_keys": "کلیدهای S/MIME و OpenPGP. برای استفادهٔ دوباره به نسخهٔ پشتیبان خودتان نیاز دارید.", + "confirm_plugins": "داده‌هایی که افزونه‌ها اینجا ذخیره کرده‌اند", + "confirm_files": "پیوست‌های بازشده برای ویرایش و فایل‌های در انتظار پیوست", + "confirm_accounts": "از همهٔ حساب‌ها خارج می‌شوید", + "confirm_button": "حذف همه چیز", + "note_unknown": "سازمان شما داده‌های این حساب را از این دستگاه حذف کرد. شاید پیام‌های ارسال‌نشده هم حذف شده باشند.", + "refused_waiting": "پیام‌های ارسال‌نشده در انتظار گذرواژه جدید شما: {count}. تا {date} نگه داشته می‌شوند.", + "refused_waiting_unknown": "شاید پیام‌های ارسال‌نشده در انتظار گذرواژه جدید شما باشند. تا {date} نگه داشته می‌شوند.", + "confirm_unsent_unknown": "شاید پیام‌های ارسال‌نشده اینجا منتظر باشند. از بین خواهند رفت.", + "confirm_templates": "الگوهای ایمیل ذخیره‌شده در این دستگاه" + }, "offlineStorage": { "keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این دستگاه نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.", "window_label": "تا چه مدت قبل نگه داشته شود", diff --git a/locales/fr/common.json b/locales/fr/common.json index 277d30d79..d12e0fd67 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5763,6 +5763,28 @@ "store_private": "Les fenêtres privées ne peuvent pas conserver le courrier pour une utilisation hors ligne.", "server_not_answering": "Le serveur de messagerie ne répond pas. Réessayez dans un instant." }, + "offlineWipe": { + "note": "Votre organisation a supprimé les données de ce compte de cet appareil. Connectez-vous pour recommencer.", + "note_unsent": "Votre organisation a supprimé les données de ce compte de cet appareil. Messages non envoyés supprimés : {count}.", + "settings_title": "Tout supprimer de cet appareil", + "settings_description": "Si vous donnez cet ordinateur ou cessez de l'utiliser. Vos messages sur le serveur restent en place.", + "settings_button": "Tout supprimer…", + "settings_wiping": "Suppression…", + "confirm_title": "Tout supprimer de cet appareil ?", + "confirm_message": "Cela supprime ce que cette application garde sur cet appareil, pour tous les comptes présents. Vos messages sur le serveur ne sont pas touchés.", + "confirm_mail": "Messages, agendas, contacts et fichiers gardés ici", + "confirm_unsent": "Messages non envoyés en attente ici : {count}. Ils seront perdus.", + "confirm_keys": "Clés S/MIME et OpenPGP. Il vous faudra votre propre sauvegarde pour les réutiliser.", + "confirm_plugins": "Données enregistrées ici par les plugins", + "confirm_files": "Pièces jointes ouvertes pour modification et fichiers en attente d'être joints", + "confirm_accounts": "Tous les comptes sont déconnectés", + "confirm_button": "Tout supprimer", + "note_unknown": "Votre organisation a supprimé les données de ce compte sur cet appareil. Des messages non envoyés ont peut-être aussi été supprimés.", + "refused_waiting": "Messages non envoyés qui attendent votre nouveau mot de passe : {count}. Ils restent jusqu'au {date}.", + "refused_waiting_unknown": "Des messages non envoyés attendent peut-être votre nouveau mot de passe. Ils restent jusqu'au {date}.", + "confirm_unsent_unknown": "Des messages non envoyés attendent peut-être ici. Ils seront perdus.", + "confirm_templates": "Modèles d'e-mail enregistrés sur cet appareil" + }, "offlineStorage": { "keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés sur cet appareil. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.", "window_label": "Durée de conservation", diff --git a/locales/he/common.json b/locales/he/common.json index 58dfb56e7..1739d0d64 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5764,6 +5764,28 @@ "store_private": "חלונות פרטיים לא יכולים לשמור דואר לשימוש לא מקוון.", "server_not_answering": "שרת הדואר לא עונה. אפשר לנסות שוב בעוד רגע." }, + "offlineWipe": { + "note": "הארגון שלך הסיר את נתוני החשבון הזה מהמכשיר הזה. יש להיכנס כדי להתחיל מחדש.", + "note_unsent": "הארגון שלך הסיר את נתוני החשבון הזה מהמכשיר הזה. הודעות שלא נשלחו ונמחקו: {count}.", + "settings_title": "הסרת הכול מהמכשיר הזה", + "settings_description": "למקרה שמעבירים את המחשב הזה למישהו אחר או מפסיקים להשתמש בו. הדואר שלך בשרת נשאר.", + "settings_button": "הסרת הכול…", + "settings_wiping": "מתבצעת הסרה…", + "confirm_title": "להסיר הכול מהמכשיר הזה?", + "confirm_message": "פעולה זו מוחקת את מה שהיישום הזה שומר במכשיר הזה, לכל החשבונות כאן. הדואר שלך בשרת לא ייפגע.", + "confirm_mail": "דואר, יומנים, אנשי קשר וקבצים השמורים כאן", + "confirm_unsent": "הודעות שלא נשלחו וממתינות כאן: {count}. הן יאבדו.", + "confirm_keys": "מפתחות S/MIME ו-OpenPGP. כדי להשתמש בהם שוב צריך גיבוי משלך.", + "confirm_plugins": "נתונים שתוספים שמרו כאן", + "confirm_files": "קבצים מצורפים שנפתחו לעריכה וקבצים שממתינים לצירוף", + "confirm_accounts": "מתבצעת יציאה מכל החשבונות", + "confirm_button": "הסרת הכול", + "note_unknown": "הארגון שלך הסיר את נתוני החשבון הזה מהמכשיר הזה. ייתכן שגם הודעות שלא נשלחו נמחקו.", + "refused_waiting": "הודעות שלא נשלחו ממתינות לסיסמה החדשה שלך: {count}. הן יישמרו עד {date}.", + "refused_waiting_unknown": "ייתכן שהודעות שלא נשלחו ממתינות לסיסמה החדשה שלך. הן יישמרו עד {date}.", + "confirm_unsent_unknown": "ייתכן שיש כאן הודעות שלא נשלחו. הן יאבדו.", + "confirm_templates": "תבניות דוא\"ל שנשמרו במכשיר הזה" + }, "offlineStorage": { "keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים במכשיר הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.", "window_label": "כמה זמן אחורה לשמור", diff --git a/locales/hu/common.json b/locales/hu/common.json index 410ba5ff5..9effe4bc5 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5763,6 +5763,28 @@ "store_private": "A privát ablakok nem tudnak leveleket megőrizni offline használatra.", "server_not_answering": "A levelezőszerver nem válaszol. Próbálja újra egy pillanat múlva." }, + "offlineWipe": { + "note": "A szervezete eltávolította a fiók adatait erről az eszközről. Jelentkezzen be az újrakezdéshez.", + "note_unsent": "A szervezete eltávolította a fiók adatait erről az eszközről. Törölt, el nem küldött üzenetek: {count}.", + "settings_title": "Minden eltávolítása erről az eszközről", + "settings_description": "Arra az esetre, ha továbbadja ezt a számítógépet, vagy már nem használja. A levelei a kiszolgálón megmaradnak.", + "settings_button": "Minden eltávolítása…", + "settings_wiping": "Eltávolítás…", + "confirm_title": "Eltávolít mindent erről az eszközről?", + "confirm_message": "Ez törli mindazt, amit ez az alkalmazás ezen az eszközön tárol, az itteni összes fiókhoz. A kiszolgálón lévő leveleit nem érinti.", + "confirm_mail": "Az itt tárolt levelek, naptárak, névjegyek és fájlok", + "confirm_unsent": "Itt várakozó, el nem küldött üzenetek: {count}. Elvesznek.", + "confirm_keys": "S/MIME- és OpenPGP-kulcsok. Az újbóli használatukhoz saját biztonsági másolatra lesz szüksége.", + "confirm_plugins": "A bővítmények által itt tárolt adatok", + "confirm_files": "Szerkesztésre megnyitott mellékletek és csatolásra váró fájlok", + "confirm_accounts": "Minden fiók kijelentkezik", + "confirm_button": "Minden eltávolítása", + "note_unknown": "A szervezete eltávolította a fiók adatait erről az eszközről. Lehet, hogy az el nem küldött üzenetek is törlődtek.", + "refused_waiting": "Az új jelszóra váró, el nem küldött üzenetek: {count}. Eddig maradnak: {date}.", + "refused_waiting_unknown": "Lehet, hogy el nem küldött üzenetek várnak az új jelszóra. Eddig maradnak: {date}.", + "confirm_unsent_unknown": "Lehet, hogy el nem küldött üzenetek várakoznak itt. Elvesznek.", + "confirm_templates": "Ezen az eszközön mentett e-mail-sablonok" + }, "offlineStorage": { "keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ezen az eszközön. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.", "window_label": "Mennyi időre visszamenőleg maradjon meg", diff --git a/locales/it/common.json b/locales/it/common.json index 051aca7a3..8bae2befc 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5763,6 +5763,28 @@ "store_private": "Le finestre private non possono conservare la posta per l'uso offline.", "server_not_answering": "Il server di posta non risponde. Riprova tra un momento." }, + "offlineWipe": { + "note": "La tua organizzazione ha rimosso i dati di questo account da questo dispositivo. Accedi per ricominciare.", + "note_unsent": "La tua organizzazione ha rimosso i dati di questo account da questo dispositivo. Messaggi non inviati eliminati: {count}.", + "settings_title": "Rimuovi tutto da questo dispositivo", + "settings_description": "Per quando cedi questo computer o smetti di usarlo. La tua posta sul server resta.", + "settings_button": "Rimuovi tutto…", + "settings_wiping": "Rimozione…", + "confirm_title": "Rimuovere tutto da questo dispositivo?", + "confirm_message": "Questo elimina ciò che questa app conserva su questo dispositivo, per tutti gli account presenti. La tua posta sul server non viene toccata.", + "confirm_mail": "Posta, calendari, contatti e file conservati qui", + "confirm_unsent": "Messaggi non inviati in attesa qui: {count}. Andranno persi.", + "confirm_keys": "Chiavi S/MIME e OpenPGP. Per riusarle ti serve un tuo backup.", + "confirm_plugins": "Dati salvati qui dai plugin", + "confirm_files": "Allegati aperti per la modifica e file in attesa di essere allegati", + "confirm_accounts": "Tutti gli account vengono disconnessi", + "confirm_button": "Rimuovi tutto", + "note_unknown": "La tua organizzazione ha rimosso i dati di questo account da questo dispositivo. Potrebbero essere stati eliminati anche messaggi non inviati.", + "refused_waiting": "Messaggi non inviati in attesa della nuova password: {count}. Restano fino al {date}.", + "refused_waiting_unknown": "Alcuni messaggi non inviati potrebbero essere in attesa della nuova password. Restano fino al {date}.", + "confirm_unsent_unknown": "Qui potrebbero esserci messaggi non inviati in attesa. Andranno persi.", + "confirm_templates": "Modelli di email salvati su questo dispositivo" + }, "offlineStorage": { "keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati su questo dispositivo. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.", "window_label": "Da quanto tempo conservare", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3b7a425db..5d337d274 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5763,6 +5763,28 @@ "store_private": "プライベートウィンドウではオフライン用にメールを保存できません。", "server_not_answering": "メールサーバーが応答していません。少ししてからもう一度お試しください。" }, + "offlineWipe": { + "note": "組織がこのアカウントのデータをこのデバイスから削除しました。もう一度始めるにはサインインしてください。", + "note_unsent": "組織がこのアカウントのデータをこのデバイスから削除しました。削除された未送信メッセージ: {count} 件。", + "settings_title": "このデバイスからすべて削除", + "settings_description": "このコンピューターを譲渡するときや使わなくなるときのためのものです。サーバー上のメールは残ります。", + "settings_button": "すべて削除…", + "settings_wiping": "削除しています…", + "confirm_title": "このデバイスからすべて削除しますか?", + "confirm_message": "このアプリがこのデバイスに保存している内容を、ここにあるすべてのアカウントについて削除します。サーバー上のメールには影響しません。", + "confirm_mail": "ここに保存されたメール、カレンダー、連絡先、ファイル", + "confirm_unsent": "ここで待機中の未送信メッセージ: {count} 件。失われます。", + "confirm_keys": "S/MIME と OpenPGP の鍵。再度使うにはご自身のバックアップが必要です。", + "confirm_plugins": "プラグインがここに保存したデータ", + "confirm_files": "編集のために開いた添付ファイルと、添付待ちのファイル", + "confirm_accounts": "すべてのアカウントがサインアウトされます", + "confirm_button": "すべて削除", + "note_unknown": "組織がこのアカウントのデータをこのデバイスから削除しました。未送信のメッセージも削除された可能性があります。", + "refused_waiting": "新しいパスワードを待っている未送信メッセージ: {count} 件。{date} まで保持されます。", + "refused_waiting_unknown": "未送信のメッセージが新しいパスワードを待っている可能性があります。{date} まで保持されます。", + "confirm_unsent_unknown": "ここに未送信のメッセージがある可能性があります。失われます。", + "confirm_templates": "このデバイスに保存されたメールテンプレート" + }, "offlineStorage": { "keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのデバイスに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。", "window_label": "保存する期間", diff --git a/locales/ko/common.json b/locales/ko/common.json index b453ba6e2..a0cc9e7a9 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5763,6 +5763,28 @@ "store_private": "개인 정보 보호 창에서는 오프라인용 메일을 보관할 수 없습니다.", "server_not_answering": "메일 서버가 응답하지 않습니다. 잠시 후 다시 시도하세요." }, + "offlineWipe": { + "note": "조직에서 이 기기의 이 계정 데이터를 제거했습니다. 다시 시작하려면 로그인하세요.", + "note_unsent": "조직에서 이 기기의 이 계정 데이터를 제거했습니다. 삭제된 보내지 않은 메시지: {count}개.", + "settings_title": "이 기기에서 모두 제거", + "settings_description": "이 컴퓨터를 다른 사람에게 주거나 더 이상 사용하지 않을 때 사용합니다. 서버의 메일은 그대로 남습니다.", + "settings_button": "모두 제거…", + "settings_wiping": "제거하는 중…", + "confirm_title": "이 기기에서 모두 제거할까요?", + "confirm_message": "이 앱이 이 기기에 보관하는 내용을 여기 있는 모든 계정에 대해 삭제합니다. 서버의 메일은 건드리지 않습니다.", + "confirm_mail": "여기에 보관된 메일, 캘린더, 연락처, 파일", + "confirm_unsent": "여기에서 대기 중인 보내지 않은 메시지: {count}개. 사라집니다.", + "confirm_keys": "S/MIME 및 OpenPGP 키. 다시 사용하려면 직접 만든 백업이 필요합니다.", + "confirm_plugins": "플러그인이 여기에 저장한 데이터", + "confirm_files": "편집하려고 연 첨부 파일과 첨부를 기다리는 파일", + "confirm_accounts": "모든 계정이 로그아웃됩니다", + "confirm_button": "모두 제거", + "note_unknown": "조직에서 이 계정의 데이터를 이 기기에서 삭제했습니다. 보내지 않은 메시지도 삭제되었을 수 있습니다.", + "refused_waiting": "새 비밀번호를 기다리는 보내지 않은 메시지: {count}개. {date}까지 보관됩니다.", + "refused_waiting_unknown": "보내지 않은 메시지가 새 비밀번호를 기다리고 있을 수 있습니다. {date}까지 보관됩니다.", + "confirm_unsent_unknown": "여기에 보내지 않은 메시지가 있을 수 있습니다. 사라지게 됩니다.", + "confirm_templates": "이 기기에 저장된 이메일 템플릿" + }, "offlineStorage": { "keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 기기에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.", "window_label": "보관할 기간", diff --git a/locales/lv/common.json b/locales/lv/common.json index 06ae58e27..3d0c0a8b7 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5763,6 +5763,28 @@ "store_private": "Privātie logi nevar glabāt pastu lietošanai bezsaistē.", "server_not_answering": "Pasta serveris neatbild. Mēģiniet vēlreiz pēc brīža." }, + "offlineWipe": { + "note": "Jūsu organizācija ir noņēmusi šī konta datus no šīs ierīces. Piesakieties, lai sāktu no jauna.", + "note_unsent": "Jūsu organizācija ir noņēmusi šī konta datus no šīs ierīces. Dzēstie nenosūtītie ziņojumi: {count}.", + "settings_title": "Noņemt visu no šīs ierīces", + "settings_description": "Gadījumam, ja atdodat šo datoru vai vairs to neizmantojat. Jūsu pasts serverī paliek.", + "settings_button": "Noņemt visu…", + "settings_wiping": "Notiek noņemšana…", + "confirm_title": "Vai noņemt visu no šīs ierīces?", + "confirm_message": "Tiek izdzēsts tas, ko šī lietotne glabā šajā ierīcē, visiem šeit esošajiem kontiem. Jūsu pasts serverī netiek skarts.", + "confirm_mail": "Šeit glabātais pasts, kalendāri, kontakti un faili", + "confirm_unsent": "Šeit gaidošie nenosūtītie ziņojumi: {count}. Tie tiks zaudēti.", + "confirm_keys": "S/MIME un OpenPGP atslēgas. Lai tās izmantotu atkal, būs vajadzīga jūsu pašu dublējumkopija.", + "confirm_plugins": "Dati, ko šeit saglabājuši spraudņi", + "confirm_files": "Rediģēšanai atvērti pielikumi un faili, kas gaida pievienošanu", + "confirm_accounts": "Visi konti tiek izrakstīti", + "confirm_button": "Noņemt visu", + "note_unknown": "Jūsu organizācija noņēma šī konta datus no šīs ierīces. Iespējams, ir izdzēsti arī nenosūtītie ziņojumi.", + "refused_waiting": "Nenosūtītie ziņojumi gaida jūsu jauno paroli: {count}. Tie paliek līdz {date}.", + "refused_waiting_unknown": "Iespējams, nenosūtītie ziņojumi gaida jūsu jauno paroli. Tie paliek līdz {date}.", + "confirm_unsent_unknown": "Šeit var būt nenosūtīti ziņojumi. Tie tiks zaudēti.", + "confirm_templates": "Šajā ierīcē saglabātās e-pasta veidnes" + }, "offlineStorage": { "keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā ierīcē. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.", "window_label": "Cik tālu atpakaļ glabāt", diff --git a/locales/mn/common.json b/locales/mn/common.json index 59782e7af..4ede4b17f 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5763,6 +5763,28 @@ "store_private": "Хувийн цонх офлайн ашиглах захидал хадгалж чадахгүй.", "server_not_answering": "Шуудангийн сервер хариу өгөхгүй байна. Хэсэг хугацааны дараа дахин оролдоно уу." }, + "offlineWipe": { + "note": "Таны байгууллага энэ бүртгэлийн өгөгдлийг энэ төхөөрөмжөөс устгасан. Дахин эхлэхийн тулд нэвтэрнэ үү.", + "note_unsent": "Таны байгууллага энэ бүртгэлийн өгөгдлийг энэ төхөөрөмжөөс устгасан. Устгасан илгээгээгүй зурвас: {count}.", + "settings_title": "Энэ төхөөрөмжөөс бүгдийг устгах", + "settings_description": "Энэ компьютерийг бусдад өгөх эсвэл ашиглахаа болих үед. Сервер дээрх таны шуудан хэвээр үлдэнэ.", + "settings_button": "Бүгдийг устгах…", + "settings_wiping": "Устгаж байна…", + "confirm_title": "Энэ төхөөрөмжөөс бүгдийг устгах уу?", + "confirm_message": "Энэ аппын энэ төхөөрөмж дээр хадгалсан бүх зүйлийг эндхийн бүх бүртгэлд устгана. Сервер дээрх таны шуудан хөндөгдөхгүй.", + "confirm_mail": "Энд хадгалсан шуудан, хуанли, харилцагч, файлууд", + "confirm_unsent": "Энд хүлээгдэж буй илгээгээгүй зурвас: {count}. Тэд алга болно.", + "confirm_keys": "S/MIME болон OpenPGP түлхүүрүүд. Дахин ашиглахын тулд өөрийн нөөц хуулбар хэрэгтэй.", + "confirm_plugins": "Өргөтгөлүүдийн энд хадгалсан өгөгдөл", + "confirm_files": "Засахаар нээсэн хавсралтууд болон хавсаргахаар хүлээж буй файлууд", + "confirm_accounts": "Бүх бүртгэлээс гарна", + "confirm_button": "Бүгдийг устгах", + "note_unknown": "Таны байгууллага энэ бүртгэлийн өгөгдлийг энэ төхөөрөмжөөс устгасан. Илгээгээгүй зурвасууд мөн устсан байж магадгүй.", + "refused_waiting": "Шинэ нууц үгийг тань хүлээж буй илгээгээгүй зурвас: {count}. {date} хүртэл хадгалагдана.", + "refused_waiting_unknown": "Илгээгээгүй зурвасууд шинэ нууц үгийг тань хүлээж байж магадгүй. {date} хүртэл хадгалагдана.", + "confirm_unsent_unknown": "Энд илгээгээгүй зурвас хүлээж байж магадгүй. Тэдгээр нь алдагдана.", + "confirm_templates": "Энэ төхөөрөмжид хадгалсан имэйлийн загварууд" + }, "offlineStorage": { "keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ төхөөрөмж дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.", "window_label": "Хэр удаан хугацаагаар хадгалах вэ", diff --git a/locales/nb/common.json b/locales/nb/common.json index 460ed0217..b507ee77d 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5763,6 +5763,28 @@ "store_private": "Private vinduer kan ikke beholde e-post for bruk uten nett.", "server_not_answering": "E-postserveren svarer ikke. Prøv igjen om litt." }, + "offlineWipe": { + "note": "Organisasjonen din har fjernet dataene for denne kontoen fra denne enheten. Logg på for å begynne på nytt.", + "note_unsent": "Organisasjonen din har fjernet dataene for denne kontoen fra denne enheten. Slettede usendte meldinger: {count}.", + "settings_title": "Fjern alt fra denne enheten", + "settings_description": "For når du gir bort denne datamaskinen eller slutter å bruke den. E-posten din på serveren blir værende.", + "settings_button": "Fjern alt…", + "settings_wiping": "Fjerner…", + "confirm_title": "Vil du fjerne alt fra denne enheten?", + "confirm_message": "Dette sletter det denne appen lagrer på denne enheten, for alle kontoer her. E-posten din på serveren blir ikke rørt.", + "confirm_mail": "E-post, kalendere, kontakter og filer som er lagret her", + "confirm_unsent": "Usendte meldinger som venter her: {count}. De går tapt.", + "confirm_keys": "S/MIME- og OpenPGP-nøkler. Du trenger din egen sikkerhetskopi for å bruke dem igjen.", + "confirm_plugins": "Data som programtillegg har lagret her", + "confirm_files": "Vedlegg som er åpnet for redigering, og filer som venter på å bli lagt ved", + "confirm_accounts": "Alle kontoer logges av", + "confirm_button": "Fjern alt", + "note_unknown": "Organisasjonen din har fjernet dataene til denne kontoen fra denne enheten. Usendte meldinger kan også være slettet.", + "refused_waiting": "Usendte meldinger som venter på det nye passordet: {count}. De blir til {date}.", + "refused_waiting_unknown": "Usendte meldinger venter kanskje på det nye passordet. De blir til {date}.", + "confirm_unsent_unknown": "Det venter kanskje usendte meldinger her. De går tapt.", + "confirm_templates": "E-postmaler lagret på denne enheten" + }, "offlineStorage": { "keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert på denne enheten. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.", "window_label": "Hvor langt tilbake som beholdes", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8b260a849..0cec4efb0 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5763,6 +5763,28 @@ "store_private": "Privévensters kunnen geen mail bewaren voor offline gebruik.", "server_not_answering": "De mailserver antwoordt niet. Probeer het zo meteen opnieuw." }, + "offlineWipe": { + "note": "Je organisatie heeft de gegevens van dit account van dit apparaat verwijderd. Meld je aan om opnieuw te beginnen.", + "note_unsent": "Je organisatie heeft de gegevens van dit account van dit apparaat verwijderd. Verwijderde onverzonden berichten: {count}.", + "settings_title": "Alles van dit apparaat verwijderen", + "settings_description": "Voor als je deze computer weggeeft of niet meer gebruikt. Je e-mail op de server blijft staan.", + "settings_button": "Alles verwijderen…", + "settings_wiping": "Verwijderen…", + "confirm_title": "Alles van dit apparaat verwijderen?", + "confirm_message": "Dit verwijdert wat deze app op dit apparaat bewaart, voor alle accounts hier. Je e-mail op de server blijft ongemoeid.", + "confirm_mail": "E-mail, agenda's, contacten en bestanden die hier bewaard zijn", + "confirm_unsent": "Onverzonden berichten die hier wachten: {count}. Die gaan verloren.", + "confirm_keys": "S/MIME- en OpenPGP-sleutels. Om ze weer te gebruiken heb je je eigen back-up nodig.", + "confirm_plugins": "Gegevens die plug-ins hier hebben opgeslagen", + "confirm_files": "Bijlagen die zijn geopend om te bewerken en bestanden die nog worden bijgevoegd", + "confirm_accounts": "Alle accounts worden afgemeld", + "confirm_button": "Alles verwijderen", + "note_unknown": "Uw organisatie heeft de gegevens van dit account van dit apparaat verwijderd. Mogelijk zijn ook niet-verzonden berichten verwijderd.", + "refused_waiting": "Niet-verzonden berichten die op uw nieuwe wachtwoord wachten: {count}. Ze blijven tot {date}.", + "refused_waiting_unknown": "Mogelijk wachten niet-verzonden berichten op uw nieuwe wachtwoord. Ze blijven tot {date}.", + "confirm_unsent_unknown": "Hier wachten mogelijk niet-verzonden berichten. Ze gaan verloren.", + "confirm_templates": "E-mailsjablonen die op dit apparaat zijn opgeslagen" + }, "offlineStorage": { "keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld op dit apparaat. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.", "window_label": "Hoe ver terug bewaren", diff --git a/locales/pl/common.json b/locales/pl/common.json index f8c9161f9..ad520117b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5763,6 +5763,28 @@ "store_private": "Okna prywatne nie mogą przechowywać poczty do użytku offline.", "server_not_answering": "Serwer poczty nie odpowiada. Spróbuj ponownie za chwilę." }, + "offlineWipe": { + "note": "Twoja organizacja usunęła dane tego konta z tego urządzenia. Zaloguj się, aby zacząć od nowa.", + "note_unsent": "Twoja organizacja usunęła dane tego konta z tego urządzenia. Usunięte niewysłane wiadomości: {count}.", + "settings_title": "Usuń wszystko z tego urządzenia", + "settings_description": "Na wypadek, gdy oddajesz ten komputer lub przestajesz go używać. Twoja poczta na serwerze zostaje.", + "settings_button": "Usuń wszystko…", + "settings_wiping": "Usuwanie…", + "confirm_title": "Usunąć wszystko z tego urządzenia?", + "confirm_message": "Spowoduje to usunięcie tego, co ta aplikacja przechowuje na tym urządzeniu, dla wszystkich kont. Twoja poczta na serwerze pozostanie nietknięta.", + "confirm_mail": "Poczta, kalendarze, kontakty i pliki przechowywane tutaj", + "confirm_unsent": "Niewysłane wiadomości czekające tutaj: {count}. Zostaną utracone.", + "confirm_keys": "Klucze S/MIME i OpenPGP. Aby znów ich używać, potrzebujesz własnej kopii zapasowej.", + "confirm_plugins": "Dane zapisane tutaj przez wtyczki", + "confirm_files": "Załączniki otwarte do edycji i pliki czekające na dołączenie", + "confirm_accounts": "Wszystkie konta zostaną wylogowane", + "confirm_button": "Usuń wszystko", + "note_unknown": "Twoja organizacja usunęła dane tego konta z tego urządzenia. Mogły też zostać usunięte niewysłane wiadomości.", + "refused_waiting": "Niewysłane wiadomości czekające na nowe hasło: {count}. Pozostaną do {date}.", + "refused_waiting_unknown": "Niewysłane wiadomości mogą czekać na nowe hasło. Pozostaną do {date}.", + "confirm_unsent_unknown": "Tutaj mogą czekać niewysłane wiadomości. Zostaną utracone.", + "confirm_templates": "Szablony e-maili zapisane na tym urządzeniu" + }, "offlineStorage": { "keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane na tym urządzeniu. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.", "window_label": "Jak daleko wstecz przechowywać", diff --git a/locales/pt/common.json b/locales/pt/common.json index 0b2a251fc..d7ccc37d4 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5763,6 +5763,28 @@ "store_private": "As janelas privadas não podem guardar correio para uso offline.", "server_not_answering": "O servidor de correio não está a responder. Tente novamente daqui a pouco." }, + "offlineWipe": { + "note": "A sua organização removeu os dados desta conta deste dispositivo. Inicie sessão para começar de novo.", + "note_unsent": "A sua organização removeu os dados desta conta deste dispositivo. Mensagens não enviadas eliminadas: {count}.", + "settings_title": "Remover tudo deste dispositivo", + "settings_description": "Para quando oferecer este computador ou deixar de o usar. O seu correio no servidor mantém-se.", + "settings_button": "Remover tudo…", + "settings_wiping": "A remover…", + "confirm_title": "Remover tudo deste dispositivo?", + "confirm_message": "Isto elimina o que esta aplicação guarda neste dispositivo, para todas as contas aqui. O seu correio no servidor não é tocado.", + "confirm_mail": "Correio, calendários, contactos e ficheiros guardados aqui", + "confirm_unsent": "Mensagens não enviadas em espera aqui: {count}. Serão perdidas.", + "confirm_keys": "Chaves S/MIME e OpenPGP. Precisa da sua própria cópia de segurança para as voltar a usar.", + "confirm_plugins": "Dados que os plugins guardaram aqui", + "confirm_files": "Anexos abertos para edição e ficheiros à espera de serem anexados", + "confirm_accounts": "Todas as contas terminam sessão", + "confirm_button": "Remover tudo", + "note_unknown": "A sua organização removeu os dados desta conta deste dispositivo. Mensagens por enviar também podem ter sido eliminadas.", + "refused_waiting": "Mensagens por enviar à espera da nova palavra-passe: {count}. Ficam até {date}.", + "refused_waiting_unknown": "Podem existir mensagens por enviar à espera da nova palavra-passe. Ficam até {date}.", + "confirm_unsent_unknown": "Podem existir mensagens por enviar aqui. Serão perdidas.", + "confirm_templates": "Modelos de e-mail guardados neste dispositivo" + }, "offlineStorage": { "keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste dispositivo. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.", "window_label": "Quanto tempo atrás manter", diff --git a/locales/ro/common.json b/locales/ro/common.json index 7186c3627..a5693b91e 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5763,6 +5763,28 @@ "store_private": "Ferestrele private nu pot păstra e-mailuri pentru utilizare offline.", "server_not_answering": "Serverul de e-mail nu răspunde. Încercați din nou imediat." }, + "offlineWipe": { + "note": "Organizația dvs. a eliminat datele acestui cont de pe acest dispozitiv. Conectați-vă pentru a începe din nou.", + "note_unsent": "Organizația dvs. a eliminat datele acestui cont de pe acest dispozitiv. Mesaje netrimise șterse: {count}.", + "settings_title": "Elimină tot de pe acest dispozitiv", + "settings_description": "Pentru când dați acest computer altcuiva sau nu îl mai folosiți. E-mailurile de pe server rămân.", + "settings_button": "Elimină tot…", + "settings_wiping": "Se elimină…", + "confirm_title": "Eliminați tot de pe acest dispozitiv?", + "confirm_message": "Se șterge ceea ce această aplicație păstrează pe acest dispozitiv, pentru toate conturile de aici. E-mailurile de pe server nu sunt atinse.", + "confirm_mail": "E-mailuri, calendare, contacte și fișiere păstrate aici", + "confirm_unsent": "Mesaje netrimise care așteaptă aici: {count}. Se vor pierde.", + "confirm_keys": "Chei S/MIME și OpenPGP. Pentru a le folosi din nou aveți nevoie de propria copie de rezervă.", + "confirm_plugins": "Date stocate aici de pluginuri", + "confirm_files": "Atașamente deschise pentru editare și fișiere care așteaptă să fie atașate", + "confirm_accounts": "Toate conturile sunt deconectate", + "confirm_button": "Elimină tot", + "note_unknown": "Organizația dvs. a eliminat datele acestui cont de pe acest dispozitiv. Este posibil să fi fost șterse și mesaje netrimise.", + "refused_waiting": "Mesaje netrimise care așteaptă parola nouă: {count}. Rămân până la {date}.", + "refused_waiting_unknown": "Este posibil ca mesaje netrimise să aștepte parola nouă. Rămân până la {date}.", + "confirm_unsent_unknown": "Este posibil să existe aici mesaje netrimise. Se vor pierde.", + "confirm_templates": "Șabloane de e-mail salvate pe acest dispozitiv" + }, "offlineStorage": { "keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate pe acest dispozitiv. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.", "window_label": "Cât de departe în urmă se păstrează", diff --git a/locales/ru/common.json b/locales/ru/common.json index 05b7baaab..741db287f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5763,6 +5763,28 @@ "store_private": "Приватные окна не могут хранить почту для работы офлайн.", "server_not_answering": "Почтовый сервер не отвечает. Повторите попытку через минуту." }, + "offlineWipe": { + "note": "Ваша организация удалила данные этой учётной записи с этого устройства. Войдите, чтобы начать заново.", + "note_unsent": "Ваша организация удалила данные этой учётной записи с этого устройства. Удалено неотправленных сообщений: {count}.", + "settings_title": "Удалить всё с этого устройства", + "settings_description": "На случай, если вы передаёте этот компьютер или больше им не пользуетесь. Ваша почта на сервере останется.", + "settings_button": "Удалить всё…", + "settings_wiping": "Удаление…", + "confirm_title": "Удалить всё с этого устройства?", + "confirm_message": "Будет удалено всё, что это приложение хранит на этом устройстве, для всех учётных записей здесь. Почта на сервере не будет затронута.", + "confirm_mail": "Почта, календари, контакты и файлы, хранящиеся здесь", + "confirm_unsent": "Неотправленных сообщений в ожидании: {count}. Они будут потеряны.", + "confirm_keys": "Ключи S/MIME и OpenPGP. Чтобы снова ими пользоваться, понадобится ваша собственная резервная копия.", + "confirm_plugins": "Данные, сохранённые здесь плагинами", + "confirm_files": "Вложения, открытые для редактирования, и файлы, ожидающие прикрепления", + "confirm_accounts": "Выполняется выход из всех учётных записей", + "confirm_button": "Удалить всё", + "note_unknown": "Ваша организация удалила данные этой учётной записи с этого устройства. Возможно, удалены и неотправленные сообщения.", + "refused_waiting": "Неотправленные сообщения ждут нового пароля: {count}. Они хранятся до {date}.", + "refused_waiting_unknown": "Возможно, неотправленные сообщения ждут нового пароля. Они хранятся до {date}.", + "confirm_unsent_unknown": "Здесь могут быть неотправленные сообщения. Они будут потеряны.", + "confirm_templates": "Шаблоны писем, сохранённые на этом устройстве" + }, "offlineStorage": { "keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде на этом устройстве. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.", "window_label": "За какой период хранить", diff --git a/locales/sk/common.json b/locales/sk/common.json index a21785e59..51853a98d 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5763,6 +5763,28 @@ "store_private": "Súkromné okná nemôžu uchovávať poštu na používanie offline.", "server_not_answering": "Poštový server neodpovedá. Skúste to o chvíľu znova." }, + "offlineWipe": { + "note": "Vaša organizácia odstránila údaje tohto účtu z tohto zariadenia. Prihláste sa a začnite znova.", + "note_unsent": "Vaša organizácia odstránila údaje tohto účtu z tohto zariadenia. Odstránené neodoslané správy: {count}.", + "settings_title": "Odstrániť všetko z tohto zariadenia", + "settings_description": "Pre prípad, že tento počítač odovzdáte ďalej alebo ho prestanete používať. Vaša pošta na serveri zostane.", + "settings_button": "Odstrániť všetko…", + "settings_wiping": "Odstraňuje sa…", + "confirm_title": "Odstrániť všetko z tohto zariadenia?", + "confirm_message": "Tým sa odstráni to, čo táto aplikácia uchováva v tomto zariadení, pre všetky tunajšie účty. Vašej pošty na serveri sa to nedotkne.", + "confirm_mail": "Pošta, kalendáre, kontakty a súbory uložené tu", + "confirm_unsent": "Neodoslané správy čakajúce tu: {count}. Stratia sa.", + "confirm_keys": "Kľúče S/MIME a OpenPGP. Na ich ďalšie použitie budete potrebovať vlastnú zálohu.", + "confirm_plugins": "Údaje, ktoré sem uložili doplnky", + "confirm_files": "Prílohy otvorené na úpravy a súbory čakajúce na priloženie", + "confirm_accounts": "Všetky účty sa odhlásia", + "confirm_button": "Odstrániť všetko", + "note_unknown": "Vaša organizácia odstránila údaje tohto účtu z tohto zariadenia. Možno boli odstránené aj neodoslané správy.", + "refused_waiting": "Neodoslané správy čakajúce na nové heslo: {count}. Zostanú do {date}.", + "refused_waiting_unknown": "Na nové heslo možno čakajú neodoslané správy. Zostanú do {date}.", + "confirm_unsent_unknown": "Možno tu čakajú neodoslané správy. Stratia sa.", + "confirm_templates": "Šablóny e-mailov uložené v tomto zariadení" + }, "offlineStorage": { "keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto zariadení. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.", "window_label": "Ako dlho do minulosti uchovávať", diff --git a/locales/th/common.json b/locales/th/common.json index 7b92fca95..3daeb90b6 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2108,6 +2108,28 @@ "store_private": "หน้าต่างส่วนตัวเก็บอีเมลไว้ใช้แบบออฟไลน์ไม่ได้", "server_not_answering": "เซิร์ฟเวอร์อีเมลไม่ตอบสนอง โปรดลองอีกครั้งในอีกสักครู่" }, + "offlineWipe": { + "note": "องค์กรของคุณนำข้อมูลของบัญชีนี้ออกจากอุปกรณ์นี้แล้ว ลงชื่อเข้าใช้เพื่อเริ่มใหม่", + "note_unsent": "องค์กรของคุณนำข้อมูลของบัญชีนี้ออกจากอุปกรณ์นี้แล้ว ข้อความที่ยังไม่ส่งที่ถูกลบ: {count}", + "settings_title": "นำทุกอย่างออกจากอุปกรณ์นี้", + "settings_description": "สำหรับเมื่อคุณจะยกคอมพิวเตอร์เครื่องนี้ให้ผู้อื่นหรือเลิกใช้ อีเมลของคุณบนเซิร์ฟเวอร์ยังอยู่", + "settings_button": "นำทุกอย่างออก…", + "settings_wiping": "กำลังนำออก…", + "confirm_title": "นำทุกอย่างออกจากอุปกรณ์นี้หรือไม่", + "confirm_message": "การทำเช่นนี้จะลบสิ่งที่แอปนี้เก็บไว้ในอุปกรณ์นี้ สำหรับทุกบัญชีที่นี่ อีเมลของคุณบนเซิร์ฟเวอร์จะไม่ถูกแตะต้อง", + "confirm_mail": "อีเมล ปฏิทิน รายชื่อ และไฟล์ที่เก็บไว้ที่นี่", + "confirm_unsent": "ข้อความที่ยังไม่ส่งที่รออยู่ที่นี่: {count} ข้อความเหล่านี้จะสูญหาย", + "confirm_keys": "คีย์ S/MIME และ OpenPGP คุณต้องมีข้อมูลสำรองของคุณเองเพื่อใช้อีกครั้ง", + "confirm_plugins": "ข้อมูลที่ปลั๊กอินเก็บไว้ที่นี่", + "confirm_files": "ไฟล์แนบที่เปิดเพื่อแก้ไขและไฟล์ที่รอแนบ", + "confirm_accounts": "ทุกบัญชีจะออกจากระบบ", + "confirm_button": "นำทุกอย่างออก", + "note_unknown": "องค์กรของคุณได้ลบข้อมูลของบัญชีนี้ออกจากอุปกรณ์นี้แล้ว ข้อความที่ยังไม่ได้ส่งอาจถูกลบด้วย", + "refused_waiting": "ข้อความที่ยังไม่ได้ส่งซึ่งรอรหัสผ่านใหม่ของคุณ: {count} เก็บไว้ถึง {date}", + "refused_waiting_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรอรหัสผ่านใหม่ของคุณ เก็บไว้ถึง {date}", + "confirm_unsent_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรออยู่ที่นี่ ข้อความเหล่านั้นจะสูญหาย", + "confirm_templates": "เทมเพลตอีเมลที่บันทึกไว้ในอุปกรณ์นี้" + }, "offlineStorage": { "keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในอุปกรณ์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ", "window_label": "เก็บย้อนหลังนานแค่ไหน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 2517fb477..4b9b55b32 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5763,6 +5763,28 @@ "store_private": "Gizli pencereler e-postaları çevrimdışı kullanım için saklayamaz.", "server_not_answering": "E-posta sunucusu yanıt vermiyor. Birazdan yeniden deneyin." }, + "offlineWipe": { + "note": "Kuruluşunuz bu hesabın verilerini bu cihazdan kaldırdı. Yeniden başlamak için oturum açın.", + "note_unsent": "Kuruluşunuz bu hesabın verilerini bu cihazdan kaldırdı. Silinen gönderilmemiş iletiler: {count}.", + "settings_title": "Bu cihazdan her şeyi kaldır", + "settings_description": "Bu bilgisayarı başkasına verdiğinizde veya kullanmayı bıraktığınızda. Sunucudaki postalarınız kalır.", + "settings_button": "Her şeyi kaldır…", + "settings_wiping": "Kaldırılıyor…", + "confirm_title": "Bu cihazdan her şey kaldırılsın mı?", + "confirm_message": "Bu işlem, bu uygulamanın buradaki tüm hesaplar için bu cihazda tuttuklarını siler. Sunucudaki postalarınıza dokunulmaz.", + "confirm_mail": "Burada tutulan postalar, takvimler, kişiler ve dosyalar", + "confirm_unsent": "Burada bekleyen gönderilmemiş iletiler: {count}. Kaybolacaklar.", + "confirm_keys": "S/MIME ve OpenPGP anahtarları. Yeniden kullanmak için kendi yedeğiniz gerekir.", + "confirm_plugins": "Eklentilerin burada sakladığı veriler", + "confirm_files": "Düzenlemek için açılan ekler ve eklenmeyi bekleyen dosyalar", + "confirm_accounts": "Tüm hesapların oturumu kapatılır", + "confirm_button": "Her şeyi kaldır", + "note_unknown": "Kuruluşunuz bu hesabın verilerini bu cihazdan kaldırdı. Gönderilmemiş iletiler de silinmiş olabilir.", + "refused_waiting": "Yeni parolanızı bekleyen gönderilmemiş iletiler: {count}. {date} tarihine kadar kalır.", + "refused_waiting_unknown": "Gönderilmemiş iletiler yeni parolanızı bekliyor olabilir. {date} tarihine kadar kalır.", + "confirm_unsent_unknown": "Burada gönderilmemiş iletiler bekliyor olabilir. Kaybolacaklar.", + "confirm_templates": "Bu cihazda kayıtlı e-posta şablonları" + }, "offlineStorage": { "keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu cihazda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.", "window_label": "Ne kadar geriye tutulsun", diff --git a/locales/uk/common.json b/locales/uk/common.json index efc829880..8b5819736 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5763,6 +5763,28 @@ "store_private": "Приватні вікна не можуть зберігати пошту для роботи офлайн.", "server_not_answering": "Поштовий сервер не відповідає. Спробуйте ще раз за хвилину." }, + "offlineWipe": { + "note": "Ваша організація видалила дані цього облікового запису з цього пристрою. Увійдіть, щоб почати знову.", + "note_unsent": "Ваша організація видалила дані цього облікового запису з цього пристрою. Видалено ненадісланих повідомлень: {count}.", + "settings_title": "Видалити все з цього пристрою", + "settings_description": "На випадок, якщо ви передаєте цей комп'ютер або більше ним не користуєтеся. Ваша пошта на сервері залишиться.", + "settings_button": "Видалити все…", + "settings_wiping": "Видалення…", + "confirm_title": "Видалити все з цього пристрою?", + "confirm_message": "Буде видалено все, що цей застосунок зберігає на цьому пристрої, для всіх облікових записів тут. Пошту на сервері це не зачепить.", + "confirm_mail": "Пошта, календарі, контакти та файли, що зберігаються тут", + "confirm_unsent": "Ненадісланих повідомлень в очікуванні: {count}. Вони будуть втрачені.", + "confirm_keys": "Ключі S/MIME та OpenPGP. Щоб знову ними користуватися, потрібна ваша власна резервна копія.", + "confirm_plugins": "Дані, збережені тут плагінами", + "confirm_files": "Вкладення, відкриті для редагування, і файли, що очікують прикріплення", + "confirm_accounts": "Вихід з усіх облікових записів", + "confirm_button": "Видалити все", + "note_unknown": "Ваша організація видалила дані цього облікового запису з цього пристрою. Можливо, видалено й ненадіслані повідомлення.", + "refused_waiting": "Ненадіслані повідомлення чекають на новий пароль: {count}. Вони зберігаються до {date}.", + "refused_waiting_unknown": "Можливо, ненадіслані повідомлення чекають на новий пароль. Вони зберігаються до {date}.", + "confirm_unsent_unknown": "Тут можуть бути ненадіслані повідомлення. Їх буде втрачено.", + "confirm_templates": "Шаблони листів, збережені на цьому пристрої" + }, "offlineStorage": { "keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді на цьому пристрої. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.", "window_label": "За який період зберігати", diff --git a/locales/vi/common.json b/locales/vi/common.json index e57089754..8ba0c8d1e 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5775,6 +5775,28 @@ "store_private": "Cửa sổ riêng tư không thể giữ thư để dùng ngoại tuyến.", "server_not_answering": "Máy chủ thư không phản hồi. Hãy thử lại sau giây lát." }, + "offlineWipe": { + "note": "Tổ chức của bạn đã xóa dữ liệu của tài khoản này khỏi thiết bị này. Hãy đăng nhập để bắt đầu lại.", + "note_unsent": "Tổ chức của bạn đã xóa dữ liệu của tài khoản này khỏi thiết bị này. Số thư chưa gửi đã xóa: {count}.", + "settings_title": "Xóa mọi thứ khỏi thiết bị này", + "settings_description": "Dành cho khi bạn tặng máy tính này hoặc không dùng nữa. Thư của bạn trên máy chủ vẫn còn.", + "settings_button": "Xóa mọi thứ…", + "settings_wiping": "Đang xóa…", + "confirm_title": "Xóa mọi thứ khỏi thiết bị này?", + "confirm_message": "Thao tác này xóa những gì ứng dụng này lưu trên thiết bị này, cho mọi tài khoản ở đây. Thư của bạn trên máy chủ không bị ảnh hưởng.", + "confirm_mail": "Thư, lịch, danh bạ và tệp được lưu ở đây", + "confirm_unsent": "Số thư chưa gửi đang chờ ở đây: {count}. Chúng sẽ bị mất.", + "confirm_keys": "Khóa S/MIME và OpenPGP. Bạn cần bản sao lưu của riêng mình để dùng lại.", + "confirm_plugins": "Dữ liệu mà các tiện ích đã lưu ở đây", + "confirm_files": "Tệp đính kèm mở để chỉnh sửa và tệp đang chờ đính kèm", + "confirm_accounts": "Tất cả tài khoản sẽ đăng xuất", + "confirm_button": "Xóa mọi thứ", + "note_unknown": "Tổ chức của bạn đã xóa dữ liệu của tài khoản này khỏi thiết bị này. Các thư chưa gửi cũng có thể đã bị xóa.", + "refused_waiting": "Thư chưa gửi đang chờ mật khẩu mới của bạn: {count}. Được giữ đến {date}.", + "refused_waiting_unknown": "Có thể có thư chưa gửi đang chờ mật khẩu mới của bạn. Được giữ đến {date}.", + "confirm_unsent_unknown": "Có thể có thư chưa gửi đang chờ ở đây. Chúng sẽ bị mất.", + "confirm_templates": "Mẫu email đã lưu trên thiết bị này" + }, "offlineStorage": { "keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trên thiết bị này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.", "window_label": "Giữ lại bao lâu", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index bd69c19d3..01ce18e04 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5763,6 +5763,28 @@ "store_private": "私密視窗無法保留供離線使用的郵件。", "server_not_answering": "郵件伺服器沒有回應。請稍後再試。" }, + "offlineWipe": { + "note": "您的組織已從此裝置移除此帳號的資料。請登入以重新開始。", + "note_unsent": "您的組織已從此裝置移除此帳號的資料。已刪除的未傳送郵件:{count} 封。", + "settings_title": "從此裝置移除所有內容", + "settings_description": "用於將這台電腦送人或不再使用時。伺服器上的郵件會保留。", + "settings_button": "移除所有內容…", + "settings_wiping": "正在移除…", + "confirm_title": "要從此裝置移除所有內容嗎?", + "confirm_message": "這會刪除此應用程式為這裡所有帳號儲存在此裝置上的內容。伺服器上的郵件不受影響。", + "confirm_mail": "儲存在這裡的郵件、行事曆、聯絡人和檔案", + "confirm_unsent": "在這裡等待的未傳送郵件:{count} 封。它們將遺失。", + "confirm_keys": "S/MIME 和 OpenPGP 金鑰。要再次使用,需要您自己的備份。", + "confirm_plugins": "外掛程式儲存在這裡的資料", + "confirm_files": "為編輯而開啟的附件和等待附加的檔案", + "confirm_accounts": "所有帳號都將登出", + "confirm_button": "移除所有內容", + "note_unknown": "您的組織已從此裝置移除此帳戶的資料。未傳送的郵件可能也已被刪除。", + "refused_waiting": "等待新密碼的未傳送郵件:{count} 封。保留至 {date}。", + "refused_waiting_unknown": "可能有未傳送的郵件正在等待新密碼。保留至 {date}。", + "confirm_unsent_unknown": "這裡可能有未傳送的郵件。它們將會遺失。", + "confirm_templates": "儲存在此裝置上的郵件範本" + }, "offlineStorage": { "keep_description": "在此裝置上加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。", "window_label": "保留多久以內的內容", diff --git a/locales/zh/common.json b/locales/zh/common.json index 4356828ad..54d3eac4b 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5763,6 +5763,28 @@ "store_private": "隐私窗口无法保留供离线使用的邮件。", "server_not_answering": "邮件服务器没有响应。请稍后再试。" }, + "offlineWipe": { + "note": "您的组织已从此设备移除此帐户的数据。请登录以重新开始。", + "note_unsent": "您的组织已从此设备移除此帐户的数据。已删除的未发送邮件:{count} 封。", + "settings_title": "从此设备移除所有内容", + "settings_description": "用于将这台电脑送人或不再使用时。服务器上的邮件会保留。", + "settings_button": "移除所有内容…", + "settings_wiping": "正在移除…", + "confirm_title": "要从此设备移除所有内容吗?", + "confirm_message": "这会删除此应用为这里所有帐户保存在此设备上的内容。服务器上的邮件不受影响。", + "confirm_mail": "保存在这里的邮件、日历、联系人和文件", + "confirm_unsent": "在这里等待的未发送邮件:{count} 封。它们将丢失。", + "confirm_keys": "S/MIME 和 OpenPGP 密钥。要再次使用,需要您自己的备份。", + "confirm_plugins": "插件保存在这里的数据", + "confirm_files": "为编辑而打开的附件和等待添加的文件", + "confirm_accounts": "所有帐户都将退出登录", + "confirm_button": "移除所有内容", + "note_unknown": "您的组织已从此设备中删除此账户的数据。未发送的邮件可能也已被删除。", + "refused_waiting": "等待新密码的未发送邮件:{count} 封。保留至 {date}。", + "refused_waiting_unknown": "可能有未发送的邮件在等待新密码。保留至 {date}。", + "confirm_unsent_unknown": "这里可能有未发送的邮件。它们将会丢失。", + "confirm_templates": "保存在此设备上的邮件模板" + }, "offlineStorage": { "keep_description": "在此设备上加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。", "window_label": "保留多久以内的内容", diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index 9396192d3..088f3533a 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -100,6 +100,8 @@ export const LITE_API_STRING_ALLOWLIST = [ "/api/plugin-secrets", // stores/settings-store.ts - settingsSyncEnabled is pinned off. "/api/settings", + // lib/devices/client.ts - offline mode and its wipes aren't in Lite (no OfflineHost, IS_LITE guards in the auth store). + "/api/devices", // Stalwart's own account discovery (lib/auth/lite-oauth.ts), not a route of this app. "/api/discover", // OAuth / SSO / pairing - Lite discovers and redeems codes in the browser diff --git a/stores/__tests__/auth-store-device-wipe.test.ts b/stores/__tests__/auth-store-device-wipe.test.ts new file mode 100644 index 000000000..1f1d9a00b --- /dev/null +++ b/stores/__tests__/auth-store-device-wipe.test.ts @@ -0,0 +1,364 @@ +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Where the auth store meets wipes: the check at a start comes before any local +// data, a refused login fires the hook on every path and only ever hides the data, +// and the organisation's session limit isn't a refusal at all. + +const clients = vi.hoisted(() => ({ + connect: (async () => {}) as (client: unknown) => Promise, + made: [] as Array<{ refused?: (ms: number) => void }>, +})); + +vi.mock('@/lib/jmap/client', () => { + class JMAPClient { + refused?: (ms: number) => void; + constructor(public serverUrl: string, public username: string) { clients.made.push(this); } + static withBearer(serverUrl: string, _token: string, username: string) { return new JMAPClient(serverUrl, username); } + static fromOfflineSession(serverUrl: string, username: string) { return new JMAPClient(serverUrl, username); } + async connect() { await clients.connect(this); } + getAuthHeader() { return 'Basic x'; } + onConnectionChange() {} + onRateLimit() {} + onCredentialsRefused(callback: (ms: number) => void) { this.refused = callback; } + getRateLimitRemainingMs() { return 0; } + disconnect() {} + getUsername() { return this.username; } + async getIdentities() { return []; } + supportsContacts() { return false; } + supportsPrincipals() { return false; } + supportsVacationResponse() { return false; } + supportsCalendars() { return false; } + supportsSieve() { return false; } + getSharedAccounts() { return []; } + getAccountInfo() { return undefined; } + } + class RateLimitError extends Error {} + class RequestTimeoutError extends Error {} + return { JMAPClient, RateLimitError, RequestTimeoutError, generateMessageId: () => '' }; +}); + +const order = vi.hoisted(() => [] as string[]); +const gate = vi.hoisted(() => ({ run: (async () => {}) as () => Promise })); +vi.mock('@/lib/offline/device-wipe', () => ({ + runDeviceGate: async () => { order.push('gate'); await gate.run(); order.push('gate done'); }, +})); +vi.mock('@/lib/offline/offline-mode', () => ({ + registerOfflineHooks: () => {}, + offlineSessionFor: async () => { order.push('local copy'); return {}; }, + markStartedOffline: () => {}, +})); + +const heard = vi.hoisted(() => [] as string[]); +const certain = vi.hoisted(() => [] as boolean[]); +vi.mock('@/lib/offline/credentials-rejected', () => ({ + noteCredentialsRejected: (account: string, sure = true) => { heard.push(account); certain.push(sure); }, + onCredentialsRejected: () => () => {}, +})); + +import { readRefusedLogins, CREDENTIALS_REFUSED_CONFIRM_MS, markWipePending, clearWipePending } from '@/lib/devices/wipe-state'; + +// Fresh for each test: a sign-out leaves slots closing in the module for a while. +let isEvicted: typeof import('@/lib/offline/state').isEvicted; +let useOfflineStore: typeof import('@/lib/offline/state').useOfflineStore; +let useAccountStore: typeof import('@/stores/account-store').useAccountStore; +let useAuthStore: typeof import('@/stores/auth-store').useAuthStore; + +const ALICE = 'alice@mail.example.test'; +const BOB = 'bob@mail.example.test'; + +function account(id: string, slot: number, authMode: 'basic' | 'oauth') { + return { + id, label: id, serverUrl: 'https://mail.example.test', username: id.split('@mail')[0], authMode, rememberMe: true, + displayName: id, email: id, lastLoginAt: 1, isConnected: false, hasError: false, isDefault: slot === 0, cookieSlot: slot, avatarColor: '#000', + }; +} + +/** How accounts leave: 'explicit' is what takes their data with it. */ +async function removalReasons(): Promise { + const { onAccountRemoved } = await import('@/stores/account-store'); + const reasons: string[] = []; + onAccountRemoved((_id, reason) => { reasons.push(reason); }); + return reasons; +} + +type Answer = (url: string, method: string) => Response | null; +let answer: Answer; +const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +beforeEach(async () => { + vi.resetModules(); + ({ useOfflineStore, isEvicted } = await import('@/lib/offline/state')); + ({ useAccountStore } = await import('@/stores/account-store')); + ({ useAuthStore } = await import('@/stores/auth-store')); + const nav = await import('@/lib/browser-navigation'); + localStorage.clear(); + sessionStorage.clear(); + order.length = 0; + heard.length = 0; + certain.length = 0; + clients.made.length = 0; + clients.connect = async () => {}; + gate.run = async () => {}; + answer = () => null; + vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => { + const url = String(input); + const given = answer(url, init?.method ?? 'GET'); + if (given) return given; + if (url.includes('/api/auth/session') && init?.method === 'PUT') return json(200, { serverUrl: 'https://mail.example.test', username: 'alice', password: 'p' }); + if (url.includes('/api/auth/token') && init?.method === 'PUT') return json(200, { access_token: 't', expires_in: 3600 }); + return json(200, {}); + })); + useOfflineStore.setState({ enabled: [] }); + useAuthStore.setState({ isAuthenticated: false, activeAccountId: null, client: null }); + useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null } as never); +}, 60_000); + +describe('a start', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE }); + }); + + it('with mail kept here checks for a wipe before it reads the local copy', async () => { + useOfflineStore.setState({ enabled: [ALICE] }); + await useAuthStore.getState().checkAuth(); + expect(order.slice(0, 3)).toEqual(['gate', 'gate done', 'local copy']); + }); + + it('with no mail here and nothing waiting doesn\'t load any of it', async () => { + await useAuthStore.getState().checkAuth(); + expect(order).toEqual([]); + }); + + it('with a wipe cut short looks too, with offline switched off by then', async () => { + markWipePending(ALICE, 'admin'); + await useAuthStore.getState().checkAuth(); + clearWipePending(ALICE); + expect(order).toContain('gate'); + }); + + it('in the desktop app on an instance looks too', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' }; + await useAuthStore.getState().checkAuth(); + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + expect(order).toContain('gate'); + }); + + it('shows nothing of an account the check removed, and stops when none is left', async () => { + useOfflineStore.setState({ enabled: [ALICE] }); + gate.run = async () => { + useAccountStore.setState({ accounts: [], activeAccountId: null } as never); + }; + await useAuthStore.getState().checkAuth(); + expect(order).not.toContain('local copy'); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(useAuthStore.getState().isLoading).toBe(false); + expect(clients.made).toEqual([]); + }); + + it('goes on when the check itself breaks', async () => { + useOfflineStore.setState({ enabled: [ALICE] }); + gate.run = async () => { throw new Error('no'); }; + await useAuthStore.getState().checkAuth(); + expect(order).toContain('local copy'); + }); +}); + +describe('a login the server ended, at a start', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'oauth')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE }); + }); + + it('invalid_grant fires the hook, for certain, and only hides the data', async () => { + const reasons = await removalReasons(); + useOfflineStore.setState({ enabled: [ALICE] }); + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); + await useAuthStore.getState().checkAuth(); + // With mail kept here the real connect goes on behind the local copy. + await vi.waitFor(() => expect(heard).toEqual([ALICE])); + await vi.waitFor(() => expect(reasons).toEqual(['evicted'])); + expect(certain).toEqual([true]); + expect(isEvicted(ALICE)).toBe(true); + expect(useOfflineStore.getState().enabled).toEqual([ALICE]); + expect(Object.keys(readRefusedLogins())).toEqual([ALICE]); + }); + + it.each([ + ['a password login', 'basic', '/api/auth/session'], + ['an OAuth login', 'oauth', '/api/auth/token'], + ])('the session limit is no refusal, for %s: no hook, no wait, and the account is only dropped', async (_name, mode, route) => { + useOfflineStore.setState({ enabled: [ALICE] }); + useAccountStore.setState({ accounts: [account(ALICE, 0, mode as 'basic' | 'oauth')] } as never); + answer = (url, method) => (url.includes(route) && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); + expect(heard).toEqual([]); + expect(readRefusedLogins()).toEqual({}); + expect(isEvicted(ALICE)).toBe(true); + expect(useOfflineStore.getState().enabled).toEqual([ALICE]); + }); + + it.each([ + ['a refusal other than invalid_grant', () => json(401, { error: 'Refresh failed' })], + ['a 401 it can\'t read', () => new Response('nope', { status: 401 })], + ['a 500', () => json(500, {})], + ['a 503', () => json(503, {})], + ])('%s doesn\'t', async (_name, make) => { + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? make() : null); + await useAuthStore.getState().checkAuth(); + expect(heard).toEqual([]); + }); + + it('a missing session cookie doesn\'t', async () => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')] } as never); + answer = (url, method) => (url.includes('/api/auth/session') && method === 'PUT' ? json(401, { error: 'No session' }) : null); + await useAuthStore.getState().checkAuth(); + expect(heard).toEqual([]); + expect(readRefusedLogins()).toEqual({}); + }); +}); + +describe('a login the server ended, at a switch', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'oauth'), account(BOB, 1, 'oauth')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE, isAuthenticated: true }); + }); + + it('invalid_grant fires the hook and hides the account, keeping its data', async () => { + const reasons = await removalReasons(); + useOfflineStore.setState({ enabled: [ALICE, BOB] }); + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); + await useAuthStore.getState().switchAccount(BOB); + expect(heard).toEqual([BOB]); + expect(reasons).toEqual(['evicted']); + expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined(); + expect(isEvicted(BOB)).toBe(true); + expect(useOfflineStore.getState().enabled).toContain(BOB); + }); + + it('the session limit doesn\'t fire it and only drops the account', async () => { + useOfflineStore.setState({ enabled: [ALICE, BOB] }); + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); + await useAuthStore.getState().switchAccount(BOB); + expect(heard).toEqual([]); + expect(isEvicted(BOB)).toBe(true); + expect(useOfflineStore.getState().enabled).toContain(BOB); + }); + + it('another refusal doesn\'t', async () => { + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'Refresh failed' }) : null); + await useAuthStore.getState().switchAccount(BOB); + expect(heard).toEqual([]); + }); +}); + +describe('a password the server keeps refusing', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE }); + useOfflineStore.setState({ enabled: [ALICE] }); + }); + + it('at a start: signed out and hidden, not for certain, since one answer hasn\'t lasted', async () => { + useOfflineStore.setState({ enabled: [ALICE] }); + clients.connect = async (client) => { + (client as { refused?: (ms: number) => void }).refused?.(0); + throw new Error('Invalid username or password'); + }; + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(heard).toEqual([ALICE])); + expect(Object.keys(readRefusedLogins())).toEqual([ALICE]); + expect(certain).toEqual([false]); + expect(useAccountStore.getState().accounts).toEqual([]); + expect(isEvicted(ALICE)).toBe(true); + }); + + describe('that recovered', () => { + /** Gone from this page and back, with a session cookie that ran out: nothing says anything about the password. */ + async function backWithAFailingServer(): Promise { + useAuthStore.getState().removeAccount(BOB, 'wiped'); + useAccountStore.setState((state) => ({ accounts: [...state.accounts, account(BOB, 1, 'basic')] }) as never); + answer = (url, method) => (url.includes('/api/auth/session') && method === 'PUT' ? json(401, { error: 'No session' }) : null); + await useAuthStore.getState().switchAccount(BOB); + // Got as far as the failure that would read the mark. + expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined(); + } + + beforeEach(() => { + useOfflineStore.setState({ enabled: [] }); + useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic'), account(BOB, 1, 'basic')] } as never); + }); + + it('during the connect: a later failure isn\'t read as a refusal', async () => { + clients.connect = async (client) => { + (client as { refused?: (ms: number) => void }).refused?.(0); + }; + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(clients.made.length).toBeGreaterThanOrEqual(2)); + await vi.waitFor(() => expect(useAuthStore.getState().getClientForAccount(BOB)).toBeTruthy()); + await backWithAFailingServer(); + expect(heard).toEqual([]); + }); + + it('while running: a short refusal leaves no mark either', async () => { + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(useAuthStore.getState().getClientForAccount(BOB)).toBeTruthy()); + (useAuthStore.getState().getClientForAccount(BOB) as unknown as { refused: (ms: number) => void }).refused(0); + await backWithAFailingServer(); + expect(heard).toEqual([]); + }); + }); + + it('a refusal at a start in a page with no offline kept records no wait', async () => { + useOfflineStore.setState({ enabled: [] }); + clients.connect = async (client) => { + (client as { refused?: (ms: number) => void }).refused?.(0); + throw new Error('Invalid username or password'); + }; + await useAuthStore.getState().checkAuth(); + expect(readRefusedLogins()).toEqual({}); + expect(heard).toEqual([ALICE]); + }); + + it('at a start, with a server that only failed: no wait', async () => { + useOfflineStore.setState({ enabled: [] }); + clients.connect = async () => { throw new Error('Failed to get session: 500'); }; + await useAuthStore.getState().checkAuth(); + expect(readRefusedLogins()).toEqual({}); + expect(heard).toEqual([]); + }); + + describe('while running', () => { + async function running(): Promise<{ refused: (ms: number) => void }> { + useOfflineStore.setState({ enabled: [] }); + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(useAuthStore.getState().client).not.toBeNull()); + const client = useAuthStore.getState().client as unknown as { refused: (ms: number) => void }; + return client; + } + + it('one answer changes nothing', async () => { + const client = await running(); + client.refused(0); + client.refused(CREDENTIALS_REFUSED_CONFIRM_MS - 1); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(useAccountStore.getState().getAccountById(ALICE)).toBeDefined(); + expect(readRefusedLogins()).toEqual({}); + }); + + it('lasting a minute signs out at once and hides the data, for certain', async () => { + const client = await running(); + useOfflineStore.setState({ enabled: [ALICE] }); + client.refused(CREDENTIALS_REFUSED_CONFIRM_MS); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); + expect(Object.keys(readRefusedLogins())).toEqual([ALICE]); + expect(heard).toEqual([ALICE]); + expect(certain).toEqual([true]); + expect(isEvicted(ALICE)).toBe(true); + expect(sessionStorage.getItem('session_expired')).toBe('true'); + }); + }); +}); diff --git a/stores/__tests__/auth-store-keep-local.test.ts b/stores/__tests__/auth-store-keep-local.test.ts index 99ba77f90..a22650c61 100644 --- a/stores/__tests__/auth-store-keep-local.test.ts +++ b/stores/__tests__/auth-store-keep-local.test.ts @@ -99,20 +99,22 @@ describe('an automatic sign-out of the active account', () => { expectKept(); }); - it('wipes on a real invalid_grant, and names the lost mail', async () => { + it('keeps the data on a real invalid_grant: a password changed elsewhere gets it back with the new one', async () => { tokenAnswers(() => answer(401, { error: 'invalid_grant' })); offline.startOfflineMode(); await refresh(); - await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); - expect(localStorage.getItem(state.OFFLINE_ACCOUNTS_KEY)).toBeNull(); - expect(lost.consumeLost()).toEqual(['Unsent']); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); + expectKept(); + expect(lost.consumeLost()).toEqual([]); }); - it('wipes at the organisation\'s session limit', async () => { + it('keeps the data at the organisation\'s session limit: that says nothing about the password', async () => { const { SESSION_TOO_OLD } = await import('@/lib/session-policy/constants'); tokenAnswers(() => answer(401, { error: SESSION_TOO_OLD, maxHours: 8 })); + offline.startOfflineMode(); await refresh(); - await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); + expectKept(); }); }); @@ -134,11 +136,12 @@ describe('a background account', () => { expect(store.removed).toEqual([]); }); - it('wipes on a real invalid_grant', async () => { + it('keeps its data on a real invalid_grant too', async () => { tokenAnswers(() => answer(401, { error: 'invalid_grant' })); await token(); - await vi.waitFor(() => expect(store.removed).toEqual([BOB])); - expect(state.isEvicted(BOB)).toBe(false); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined()); + expect(state.isEvicted(BOB)).toBe(true); + expect(store.removed).toEqual([]); }); }); diff --git a/stores/account-store.ts b/stores/account-store.ts index 836dada92..714f365d6 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -5,9 +5,9 @@ import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import { forgetDesktopServer } from '@/lib/desktop/bridge'; import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils'; import { authHooks } from '@/lib/plugin-hooks'; -import { clearEvicted, markEvicted } from '@/lib/offline/state'; +import { clearEvicted, dropEvictedOwner, isEvicted, markEvicted, rememberEvictedOwner } from '@/lib/offline/state'; -export type AccountRemovalReason = 'explicit' | 'evicted'; +export type AccountRemovalReason = 'explicit' | 'evicted' | 'wiped'; const removalListeners = new Set<(accountId: string, reason: AccountRemovalReason) => void>(); @@ -124,6 +124,7 @@ export const useAccountStore = create()( const id = generateAccountId(entry.username, entry.serverUrl); clearEvicted(id); + dropEvictedOwner(id); if (state.accounts.some((a) => a.id === id)) { // Already exists - update mutable fields and return existing id set((s) => ({ @@ -177,7 +178,10 @@ export const useAccountStore = create()( removeAccount: (accountId, reason = 'explicit') => { if (!get().accounts.some((a) => a.id === accountId)) return; const leaving = get().accounts.find((a) => a.id === accountId); - if (reason === 'evicted') markEvicted(accountId); + if (reason === 'evicted') { + markEvicted(accountId); + if (IS_DESKTOP_BUILD && leaving && isEvicted(accountId)) rememberEvictedOwner(accountId, leaving); + } for (const listener of [...removalListeners]) listener(accountId, reason); set((s) => { const remaining = s.accounts.filter((a) => a.id !== accountId); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 3fbeee486..1b26004ee 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -40,7 +40,8 @@ import { withTokenLock } from '@/lib/auth/token-lock'; import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/channel'; import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role'; import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected'; -import { readEvicted, useOfflineStore } from '@/lib/offline/state'; +import { CREDENTIALS_REFUSED_CONFIRM_MS, markLoginRefused, wipeWorkWaiting } from '@/lib/devices/wipe-state'; +import { markEvicted, readEvicted, useOfflineStore } from '@/lib/offline/state'; import { toAsciiEmail } from '@/lib/idn'; import { flushComposers } from '@/lib/compose-flush-registry'; import { sortIdentities } from '@/lib/identity-order'; @@ -793,12 +794,73 @@ export function pickEndSessionSlot(accounts: AccountEntry[], activeAccountId: st return target ? target.cookieSlot : null; } +/** Accounts the server refused during a connect that hasn't reported back yet. */ +const refusedAtConnect = new Set(); +const endingRefused = new Set(); + +/** A client that's connected: what the server said on the way there is over. */ +function holdClient(accountId: string, client: JMAPClient): void { + clients.set(accountId, client); + refusedAtConnect.delete(accountId); +} + +/** + * The server refused this login, and the data hides from here on. It waits for the + * new password, or goes after 30 days, or now if the organisation asks for that + * (lib/devices/wipe-state.ts, lib/offline/device-wipe.ts). The usual cause is a + * password changed on another device: nothing goes by itself, unsent mail included. + * `certain`: `invalid_grant`, or a 401 that lasted. Not one bad answer at a connect. + */ +function noteRefusedLogin(accountId: string, certain: boolean): void { + if (useOfflineStore.getState().enabled.includes(accountId)) { + markLoginRefused(accountId, certain); + markEvicted(accountId); + } + noteCredentialsRejected(accountId, certain); +} + +/** + * The server kept refusing this login, after a fresh try each time: it signs out + * at once. It has no parsed refusal like `invalid_grant` to go by, and this is the + * only word a password login ever gets. + */ +async function endRefusedLogin(accountId: string, get: () => AuthState): Promise { + const account = useAccountStore.getState().getAccountById(accountId); + if (!account || endingRefused.has(accountId)) return; + endingRefused.add(accountId); + try { + if (get().activeAccountId !== accountId) { + noteRefusedLogin(accountId, true); + get().removeAccount(accountId, 'evicted'); + return; + } + await saveDraftsBeforeExpiry(account.cookieSlot ?? 0); + noteRefusedLogin(accountId, true); + notifyParent('sso:session-expired'); + markSessionExpired(); + await get().logout({ endProviderSession: false, keepLocalData: true }); + } finally { + endingRefused.delete(accountId); + } +} + function bindClientStatusHandlers( client: IJMAPClient, set: (state: Partial) => void, get: () => AuthState, accountId?: string, ): void { + client.onCredentialsRefused?.((refusedForMs) => { + // A pop-out is on another window's session. Lite keeps no data to wait. + if (!accountId || IS_LITE || isPopoutWindow()) return; + // Still connecting: the restore that started this decides, when it fails. + if (clients.get(accountId) !== client) { + refusedAtConnect.add(accountId); + return; + } + if (refusedForMs >= CREDENTIALS_REFUSED_CONFIRM_MS) void endRefusedLogin(accountId, get); + }); + client.onConnectionChange((connected) => { if (!accountId || get().activeAccountId === accountId) { set({ connectionLost: !connected }); @@ -1253,14 +1315,15 @@ function recordPermanentRefreshFailure(status: number, accountId?: string): bool } /** - * Whether a refused renewal ends the login for real, which wipes the account's - * local data: the server said invalid_grant, or the organisation's session limit - * was reached. Anything else, including a body we can't read, keeps the data. + * Whether the server refused the login itself: invalid_grant. That signs out and + * hides the data (noteRefusedLogin). Anything else keeps it the same way with no + * word to anyone, including a body we can't read and the organisation's session + * limit, which says the session is old and nothing about the password. */ -async function loginReallyOver(res: Response): Promise { +async function loginRefused(res: Response): Promise { try { const { error } = await res.clone().json() as { error?: unknown }; - return error === 'invalid_grant' || error === SESSION_TOO_OLD; + return error === 'invalid_grant'; } catch { return false; } @@ -1705,7 +1768,7 @@ export const useAuthStore = create()( initializeFeatureStores(client); // Store client in multi-account map - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); accountStore.addAccount({ @@ -1846,7 +1909,7 @@ export const useAuthStore = create()( const { identities, primaryIdentity } = loadIdentities(rawIdentities, username); initializeFeatureStores(client); - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); accountStore.addAccount({ @@ -1955,7 +2018,7 @@ export const useAuthStore = create()( }); // The registry is typed for the real client; the unified views only // use the IJMAPClient surface, which the demo client implements. - clients.set(id, demoClient as unknown as JMAPClient); + holdClient(id, demoClient as unknown as JMAPClient); return id; }; const demoAccountId = register(client, 'Meridian Labs', true); @@ -2062,7 +2125,7 @@ export const useAuthStore = create()( clearAllStores(); } - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); accountStore.addAccount({ @@ -2211,7 +2274,7 @@ export const useAuthStore = create()( clearAllStores(); } - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); accountStore.addAccount({ @@ -2335,17 +2398,18 @@ export const useAuthStore = create()( // maintenance windows and offline spells. if (res.status === 401) { resetRefreshBackoff(accountId ?? undefined); - const over = await loginReallyOver(res); - if (over && accountId) noteCredentialsRejected(accountId); + const refused = await loginRefused(res); // Only this login's refresh token was refused. if (accountId && !isActive) { - get().removeAccount(accountId, over ? 'explicit' : 'evicted'); + if (refused) noteRefusedLogin(accountId, true); + get().removeAccount(accountId, 'evicted'); return null; } await saveDraftsBeforeExpiry(slot); + if (refused && accountId) noteRefusedLogin(accountId, true); notifyParent('sso:session-expired'); markSessionExpired(); - get().logout({ endProviderSession: false, keepLocalData: !over }); + get().logout({ endProviderSession: false, keepLocalData: true }); return null; } // A Bulwark-side failure (500/502) that keeps repeating will not @@ -2675,6 +2739,7 @@ export const useAuthStore = create()( let targetClient = clients.get(accountId); const wasConnected = !!targetClient; let targetRestoreRateLimited = false; + let targetRefused = false; if (!targetClient) { // Null out the client immediately so the page doesn't fire data-loading @@ -2691,12 +2756,13 @@ export const useAuthStore = create()( try { if (targetAccount.authMode === 'oauth') { const res = await fetchSlotAccessToken(targetAccount.cookieSlot, { remember: targetAccount.rememberMe }); + if (!res.ok && res.status === 401) targetRefused = await loginRefused(res); if (res.ok) { const { access_token, expires_in } = await res.json(); targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => get().refreshAccessToken({ accountId })); bindClientStatusHandlers(targetClient, set, get, accountId); await targetClient.connect(); - clients.set(accountId, targetClient); + holdClient(accountId, targetClient); scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId }), accountId); await syncStalwartAuthContext( targetAccount.serverUrl, @@ -2707,12 +2773,13 @@ export const useAuthStore = create()( } } else if (targetAccount.rememberMe) { const res = await fetchSlotSession(targetAccount.cookieSlot); + if (!res.ok && res.status === 401) targetRefused = await loginRefused(res); if (res.ok) { const session: RememberedSession = await res.json(); targetClient = clientForSession(session); bindClientStatusHandlers(targetClient, set, get, accountId); await targetClient.connect(); - clients.set(accountId, targetClient); + holdClient(accountId, targetClient); await syncStalwartAuthContext(session.serverUrl, session.username, targetClient.getAuthHeader(), targetAccount.cookieSlot); } } @@ -2758,6 +2825,8 @@ export const useAuthStore = create()( } // Cannot restore - remove the stale account and redirect to login + if (targetRefused || refusedAtConnect.has(accountId)) noteRefusedLogin(accountId, targetRefused); + refusedAtConnect.delete(accountId); evictAccount(accountId); accountStore.removeAccount(accountId, 'evicted'); void clearSlotCredentials(targetAccount.cookieSlot, targetAccount.authMode === 'oauth'); @@ -2951,6 +3020,17 @@ export const useAuthStore = create()( await import('@/lib/offline/offline-mode').then((offline) => offline.registerOfflineHooks()).catch(() => {}); } + // A wipe comes before anything from this device is shown: one a closed + // window cut short, and what the instance has to say, briefly. + if (!IS_LITE && (useOfflineStore.getState().enabled.length > 0 || getDesktopBridge()?.mode === 'instance' || wipeWorkWaiting())) { + await import('@/lib/offline/device-wipe').then((wipe) => wipe.runDeviceGate()).catch(() => {}); + accounts = useAccountStore.getState().accounts; + if (accounts.length === 0) { + set({ isLoading: false, isAuthenticated: false, client: null, activeAccountId: null }); + return; + } + } + // Determine which account to activate first const defaultAccount = accountStore.getDefaultAccount(); const usable = (id: string | null | undefined) => (id && accounts.some((account) => account.id === id) ? id : null); @@ -2995,7 +3075,7 @@ export const useAuthStore = create()( bindClientStatusHandlers(client, set, get, account.id); const contextSync = syncStalwartAuthContext(account.serverUrl, account.username, client.getAuthHeader(), account.cookieSlot); await client.connect(); - clients.set(account.id, client); + holdClient(account.id, client); scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId: account.id }), account.id); await contextSync; accountStore.updateAccount(account.id, { isConnected: true, hasError: false }); @@ -3006,7 +3086,7 @@ export const useAuthStore = create()( // Repeated 500/502 (see recordPermanentRefreshFailure) falls // through here and evicts the account like a rejection. (#972) if (res.status >= 500) notifySignInAgain(); - throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { loginOver: await loginReallyOver(res) }); + throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { refused: await loginRefused(res) }); } } else { const res = await fetchSlotSession(account.cookieSlot); @@ -3016,7 +3096,7 @@ export const useAuthStore = create()( bindClientStatusHandlers(client, set, get, account.id); const contextSync = syncStalwartAuthContext(session.serverUrl, session.username, client.getAuthHeader(), account.cookieSlot); await client.connect(); - clients.set(account.id, client); + holdClient(account.id, client); await contextSync; accountStore.updateAccount(account.id, { isConnected: true, @@ -3027,7 +3107,7 @@ export const useAuthStore = create()( } else if (res.status >= 500 || res.status === 429) { throw new TransientAuthError('Session restore failed', res.status); } else { - throw new Error(`Session cookie missing: ${res.status}`); + throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { refused: await loginRefused(res) }); } } } catch (err) { @@ -3050,7 +3130,7 @@ export const useAuthStore = create()( // only; a reload once the server is back signs in as usual. const offlineClient = await startOfflineClient(account.id, account.serverUrl, account.username); if (offlineClient) { - clients.set(account.id, offlineClient); + holdClient(account.id, offlineClient); accountStore.updateAccount(account.id, { isConnected: true, hasError: false }); return; } @@ -3064,9 +3144,12 @@ export const useAuthStore = create()( // Remove unrestorable accounts so the user is prompted to log in // again rather than seeing a stale error entry forever. Its // refresh token goes with it: nothing would use or revoke it. Its - // local data waits, unless the server really ended the login. + // local data waits. + const refused = (err as { refused?: boolean }).refused === true; + if (refused || refusedAtConnect.has(account.id)) noteRefusedLogin(account.id, refused); + refusedAtConnect.delete(account.id); evictAccount(account.id); - accountStore.removeAccount(account.id, (err as { loginOver?: boolean }).loginOver ? 'explicit' : 'evicted'); + accountStore.removeAccount(account.id, 'evicted'); void clearSlotCredentials(account.cookieSlot, account.authMode === 'oauth'); } }; @@ -3083,7 +3166,7 @@ export const useAuthStore = create()( ? await startLocalCopyClient(targetEntry.id, targetEntry.serverUrl, targetEntry.username) : null; if (localCopy && targetEntry) { - clients.set(targetEntry.id, localCopy); + holdClient(targetEntry.id, localCopy); accountStore.updateAccount(targetEntry.id, { isConnected: true, hasError: false }); } else if (targetEntry) { await restoreAccount(targetEntry); @@ -3101,7 +3184,7 @@ export const useAuthStore = create()( const real = clients.get(entry.id); if (!real) { if (useAccountStore.getState().getAccountById(entry.id)) { - clients.set(entry.id, local); + holdClient(entry.id, local); // A 5xx or a rate limit, with the server up otherwise. The app stays // on the local copy, and says so, and leaves it when the server answers. void import('@/lib/offline/offline-mode').then((offline) => offline.markStartedOffline()).catch(() => {}); @@ -3283,7 +3366,7 @@ export const useAuthStore = create()( await client.connect(); const accountId = generateAccountId(state.username || '', state.serverUrl); - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); // Migrate to account registry @@ -3351,7 +3434,7 @@ export const useAuthStore = create()( await client.connect(); const accountId = generateAccountId(username, serverUrl); - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); // Migrate to account registry @@ -3462,7 +3545,7 @@ export const useAuthStore = create()( rememberMe || IS_LITE ? persistSession(cookieSlot, { serverUrl, username, password }) : null, syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot), ]); - clients.set(accountId, client); + holdClient(accountId, client); bindClientStatusHandlers(client, set, get, accountId); accountStore.updateAccount(accountId, { awaitingSignIn: false, isConnected: true, hasError: false, errorMessage: undefined, @@ -3537,14 +3620,14 @@ export const useAuthStore = create()( if (!client) return null; bindClientStatusHandlers(client, set, get, accountId); await client.connect(); - clients.set(accountId, client); + holdClient(accountId, client); accountStore.updateAccount(accountId, { isConnected: true, hasError: false }); return client; } catch (error) { debug.error(`A pop-out could not use account ${accountId}:`, error); if (!isTransientAuthError(error)) return null; const offlineClient = await startOfflineClient(accountId, account.serverUrl, account.username); - if (offlineClient) clients.set(accountId, offlineClient); + if (offlineClient) holdClient(accountId, offlineClient); return offlineClient; } };