Merge branch 'feat/do-remote-wipe' into feat/desktop-offline

This commit is contained in:
Linus Rath
2026-11-03 16:20:09 +01:00
84 changed files with 6321 additions and 73 deletions
+2
View File
@@ -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({
</MainWindowOnly>
{/* Offline mode isn't in Lite (no service worker there). */}
{!IS_LITE && <OfflineHost />}
{!IS_LITE && <DeviceWipeHost />}
{!IS_LITE && <NotificationActionsHost />}
<DevicePolicyHost />
<MainWindowOnly>
+24
View File
@@ -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<SessionEndReason | null>(null);
// Offline outbox messages a sign-out deleted unsent (#523), named once.
const [outboxLost, setOutboxLost] = useState<string[]>([]);
// Unsent mail a wipe took with it: how many, never which.
const [wipeLost, setWipeLost] = useState<WipeNote | null>(null);
// Unsent mail that waits for a new password: how many and until when, never which.
const [refusedWaiting, setRefusedWaiting] = useState<RefusedWait[]>([]);
useEffect(() => {
setOutboxLost(consumeLost());
setWipeLost(consumeWipeNote());
const read = () => setRefusedWaiting(refusedWaits());
read();
return onRefusedLoginsChanged(read);
}, []);
const [expiredDraftsUnsaved, setExpiredDraftsUnsaved] = useState(false);
const [showPassword, setShowPassword] = useState(false);
@@ -1498,6 +1509,19 @@ function LoginPageContent() {
{/* Form section */}
<div className="px-8 pb-8">
{wipeLost !== null && (
<div className="mb-5 p-3 rounded-xl border border-warning/30 bg-warning/5 text-sm text-foreground" role="status" data-testid="device-wipe-note">
{wipeNoteText(wipeLost, tOfflineWipe)}
</div>
)}
{refusedWaiting.map((wait) => {
const date = new Date(wait.until).toLocaleDateString(String(params?.locale ?? "en"), { dateStyle: "medium" });
return (
<div key={wait.until} className="mb-5 p-3 rounded-xl border border-warning/30 bg-warning/5 text-sm text-foreground" role="status" data-testid="login-refused-waiting">
{wait.unsent === null ? tOfflineWipe("refused_waiting_unknown", { date }) : tOfflineWipe("refused_waiting", { count: wait.unsent, date })}
</div>
);
})}
{outboxLost.length > 0 && (
<div className="mb-5 p-3 rounded-xl border border-warning/30 bg-warning/5 text-sm text-foreground [overflow-wrap:anywhere]" role="status" data-testid="session-outbox-lost">
{tOffline("signout.lost", { subjects: outboxLost.map((s) => s || tOffline("outbox.no_subject")).join(", ") })}
@@ -0,0 +1,93 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { DeviceView } from '@/lib/devices/types';
import { DeviceRegistryPanel } from '../device-registry-panel';
const device = (over: Partial<DeviceView>): DeviceView => ({
id: `${'a'.repeat(64)}/${'b'.repeat(32)}`,
userKey: 'a'.repeat(64),
user: 'alice@example.org',
server: 'https://mail.example.org',
platform: 'Chrome, Windows',
appVersion: '1.2.3',
firstSeen: '2026-11-01T10:00:00.000Z',
lastSeen: '2026-11-03T10:00:00.000Z',
wipe: null,
wipedAt: null,
wipeReason: null,
...over,
});
let rows: DeviceView[];
let posts: Array<Record<string, unknown>>;
beforeEach(() => {
posts = [];
rows = [device({})];
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => {
if (init?.method === 'POST') {
posts.push(JSON.parse(String(init.body)));
return new Response('{}', { status: 200 });
}
expect(String(input)).toContain('/api/admin/devices');
return new Response(JSON.stringify({ devices: rows, total: rows.length }), { status: 200 });
}));
});
describe('the registered devices', () => {
it('lists user, platform, version and last seen', async () => {
render(<DeviceRegistryPanel />);
const row = await screen.findByTestId('device-row');
expect(screen.getByText('alice@example.org')).toBeTruthy();
expect(row.textContent).toContain('Chrome, Windows');
expect(row.textContent).toContain('Version 1.2.3');
expect(row.textContent).toContain('Last seen');
});
it('wipes only after the confirmation, naming who and what', async () => {
render(<DeviceRegistryPanel />);
fireEvent.click(await screen.findByTestId('device-wipe'));
expect(posts).toEqual([]);
const dialog = await screen.findByRole('alertdialog').catch(() => screen.getByRole('dialog'));
expect(dialog.textContent).toContain('alice@example.org');
expect(dialog.textContent).toContain('Mail that was waiting to be sent is lost');
expect(dialog.textContent).toContain('S/MIME and OpenPGP keys imported in that browser or app go for every account there');
fireEvent.click(within(dialog).getByRole('button', { name: 'Wipe' }));
await waitFor(() => expect(posts).toEqual([{ action: 'wipe', id: rows[0].id }]));
});
it('offers all of a user\'s devices once there are several', async () => {
rows = [device({}), device({ id: `${'a'.repeat(64)}/${'c'.repeat(32)}`, platform: 'Desktop app, Windows' })];
render(<DeviceRegistryPanel />);
fireEvent.click(await screen.findByTestId('devices-wipe-user'));
const dialog = await screen.findByRole('alertdialog').catch(() => screen.getByRole('dialog'));
fireEvent.click(within(dialog).getByRole('button', { name: 'Wipe' }));
await waitFor(() => expect(posts).toEqual([{ action: 'wipe-user', userKey: 'a'.repeat(64) }]));
});
it('says a wipe is waiting and lets the admin take it back', async () => {
rows = [device({ wipe: { requestedAt: '2026-11-03T11:00:00.000Z', by: 'admin' } })];
render(<DeviceRegistryPanel />);
const row = await screen.findByTestId('device-row');
expect(row.textContent).toContain('Wipe waiting');
expect(screen.queryByTestId('device-wipe')).toBeNull();
fireEvent.click(screen.getByTestId('device-cancel'));
await waitFor(() => expect(posts).toEqual([{ action: 'cancel', id: rows[0].id }]));
});
it('shows a wiped device as such, with nothing more to wipe', async () => {
rows = [device({ wipedAt: '2026-11-03T12:00:00.000Z', wipeReason: 'user' })];
render(<DeviceRegistryPanel />);
const row = await screen.findByTestId('device-row');
expect(row.textContent).toContain('Wiped');
expect(row.textContent).toContain('by the user');
expect(screen.queryByTestId('device-wipe')).toBeNull();
expect(screen.getByTestId('device-forget')).toBeTruthy();
});
it('says so when nothing registered', async () => {
rows = [];
render(<DeviceRegistryPanel />);
expect(await screen.findByTestId('devices-empty')).toBeTruthy();
});
});
@@ -0,0 +1,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<DeviceView[] | null>(null);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [query, setQuery] = useState('');
const [search, setSearch] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [pending, setPending] = useState<Pending | null>(null);
const load = useCallback(async () => {
try {
const params = new URLSearchParams({ offset: String(offset), limit: String(PAGE_SIZE) });
if (search) params.set('q', search);
const res = await apiFetch(`/api/admin/devices?${params}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json() as { devices: DeviceView[]; total: number };
setDevices(data.devices);
setTotal(data.total);
setError(null);
} catch (e) {
setError(`Could not load the devices (${(e as Error).message})`);
}
}, [offset, search]);
useEffect(() => { void load(); }, [load]);
const act = async (key: string, body: Record<string, unknown>) => {
setBusy(key);
try {
const res = await apiFetch('/api/admin/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await load();
} catch (e) {
setError(`That didn't work (${(e as Error).message})`);
} finally {
setBusy(null);
}
};
const groups = useMemo(() => {
const byUser = new Map<string, DeviceView[]>();
for (const device of devices ?? []) {
const rows = byUser.get(device.userKey) ?? [];
rows.push(device);
byUser.set(device.userKey, rows);
}
return [...byUser.entries()].map(([userKey, rows]) => ({
userKey,
user: rows[0].user,
server: rows[0].server,
rows: [...rows].sort((a, b) => b.lastSeen.localeCompare(a.lastSeen)),
}));
}, [devices]);
const confirmPending = () => {
if (!pending) return;
setPending(null);
if (pending.kind === 'device') void act(pending.device.id, { action: 'wipe', id: pending.device.id });
else if (pending.kind === 'forget') void act(pending.device.id, { action: 'forget', id: pending.device.id });
else void act(pending.userKey, { action: 'wipe-user', userKey: pending.userKey });
};
return (
<SettingsSection
id="registered"
title="Registered devices"
description="Browsers with offline mode on and desktop apps. A wipe deletes the mail, keys and files kept on the device and signs the account out the next time the device reaches this instance. A device that was signed out or had its browser data cleared has nothing left, and can't confirm: its row keeps saying the wipe is waiting."
>
<form
className="flex flex-wrap items-center gap-2 pb-3"
onSubmit={(event) => { event.preventDefault(); setOffset(0); setSearch(query.trim()); }}
>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by user"
aria-label="Search by user"
className="h-8 min-w-48 flex-1 rounded-md border border-input bg-background px-2 text-sm"
data-testid="devices-search"
/>
<Button type="submit" variant="outline" size="sm" className="h-8 text-xs">Search</Button>
</form>
{error && <p className="pb-3 text-sm text-destructive" role="alert">{error}</p>}
{devices !== null && devices.length === 0 && (
<p className="py-3 text-sm text-muted-foreground" data-testid="devices-empty">
{search ? 'No device matches that search.' : 'No device has registered yet.'}
</p>
)}
<div className="space-y-4">
{groups.map((group) => {
const wipeable = group.rows.filter((device) => !device.wipe && !device.wipedAt);
return (
<div key={group.userKey} data-testid="devices-user" data-user={group.user}>
<div className="flex flex-wrap items-center justify-between gap-2 pb-2">
<p className="min-w-0 text-sm font-medium text-foreground [overflow-wrap:anywhere]">
{group.user}
<span className="ms-2 text-xs font-normal text-muted-foreground">{group.server}</span>
</p>
{wipeable.length > 1 && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
disabled={busy === group.userKey}
onClick={() => setPending({ kind: 'user', userKey: group.userKey, user: group.user, count: wipeable.length })}
data-testid="devices-wipe-user"
>
<Trash2 className="h-3.5 w-3.5" /> Wipe all {wipeable.length} devices
</Button>
)}
</div>
<AdminList>
{group.rows.map((device) => (
<AdminListRow
key={device.id}
data-testid="device-row"
title={device.platform || 'Unknown device'}
titleExtra={device.wipe
? <Badge tone="warning">Wipe waiting</Badge>
: device.wipedAt
? <Badge tone="muted">Wiped</Badge>
: null}
description={device.wipe
? `Asked ${when(device.wipe.requestedAt)}. Happens the next time this device connects.`
: device.wipedAt
? `Wiped ${when(device.wipedAt)}${device.wipeReason && device.wipeReason !== 'admin' ? ` (${wipeReasonLabel(device.wipeReason)})` : ''}`
: undefined}
meta={`${device.appVersion ? `Version ${device.appVersion} · ` : ''}Last seen ${when(device.lastSeen)} · First seen ${when(device.firstSeen)}`}
control={(
<div className="flex flex-wrap items-center justify-end gap-2">
{!device.wipe && !device.wipedAt && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
disabled={busy === device.id}
onClick={() => setPending({ kind: 'device', device })}
data-testid="device-wipe"
>
<Trash2 className="h-3.5 w-3.5" /> Wipe
</Button>
)}
{device.wipe && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
disabled={busy === device.id}
onClick={() => { void act(device.id, { action: 'cancel', id: device.id }); }}
data-testid="device-cancel"
>
<X className="h-3.5 w-3.5" /> Take back
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 text-xs text-muted-foreground"
disabled={busy === device.id}
onClick={() => setPending({ kind: 'forget', device })}
data-testid="device-forget"
>
Forget
</Button>
</div>
)}
/>
))}
</AdminList>
</div>
);
})}
</div>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between gap-2 pt-3 text-xs text-muted-foreground">
<span>{offset + 1} to {Math.min(offset + PAGE_SIZE, total)} of {total}</span>
<span className="flex gap-2">
<Button type="button" variant="outline" size="sm" className="h-8 text-xs" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>Previous</Button>
<Button type="button" variant="outline" size="sm" className="h-8 text-xs" disabled={offset + PAGE_SIZE >= total} onClick={() => setOffset(offset + PAGE_SIZE)}>Next</Button>
</span>
</div>
)}
<ConfirmDialog
isOpen={pending !== null}
onClose={() => setPending(null)}
onConfirm={confirmPending}
title={pending?.kind === 'forget' ? 'Forget this device?' : 'Wipe this data?'}
message={confirmMessage(pending)}
confirmText={pending?.kind === 'forget' ? 'Forget' : 'Wipe'}
variant="destructive"
/>
</SettingsSection>
);
}
function wipeReasonLabel(reason: NonNullable<DeviceView['wipeReason']>): string {
switch (reason) {
case 'user': return 'by the user';
case 'rejected': return 'the server refused the login';
default: return reason;
}
}
function confirmMessage(pending: Pending | null): string {
if (!pending) return '';
const consequences = 'Mail, keys and files kept on it are deleted and the account is signed out the next time it connects. Mail that was waiting to be sent is lost. S/MIME and OpenPGP keys imported in that browser or app go for every account there, because they are not kept per account: anyone using them has to import them again. Keys in the OS certificate store stay. The mail on the server is not touched.';
if (pending.kind === 'device') return `${pending.device.platform || 'This device'} of ${pending.device.user}. ${consequences}`;
if (pending.kind === 'user') return `All ${pending.count} devices of ${pending.user}. ${consequences}`;
return pending.device.wipe
? `The wipe waiting for ${pending.device.platform || 'this device'} of ${pending.device.user} is dropped with its row. The device will not be told.`
: `${pending.device.platform || 'This device'} of ${pending.device.user} leaves this list. It comes back as a new row if it registers again.`;
}
+4
View File
@@ -63,6 +63,10 @@ export function LogsPage() {
<option value="config.update">Config Update</option>
<option value="config.revert">Config Revert</option>
<option value="policy.update">Policy Update</option>
<option value="device.wipe.request">Device Wipe Requested</option>
<option value="device.wipe.confirm">Device Wipe Confirmed</option>
<option value="device.wipe.cancel">Device Wipe Cancelled</option>
<option value="device.forget">Device Forgotten</option>
</select>
<Button type="button" variant="outline" size="sm" className="gap-2" onClick={fetchLogs} disabled={loading}>
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin [animation-direction:reverse]')} />
+2
View File
@@ -4,6 +4,7 @@ import { OrgPage } from '@/components/admin/org/org-page';
import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
import { DevicesSection } from '@/components/admin/org/sections/devices-section';
import { OfflineSection } from '@/components/admin/org/sections/offline-section';
import { DeviceRegistryPanel } from './device-registry-panel';
export function OrgDevicesPage() {
const draft = useOrgPolicyDraft('org-devices');
@@ -11,6 +12,7 @@ export function OrgDevicesPage() {
<OrgPage pageId="org-devices" draft={draft}>
<DevicesSection draft={draft} />
<OfflineSection draft={draft} />
<DeviceRegistryPanel />
</OrgPage>
);
}
@@ -0,0 +1,91 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { NextRequest } from 'next/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The real admin check, not a stand-in: a wipe request from another site carries
// the admin's cookie, and only the same-origin check stands in its way.
const jar = vi.hoisted(() => new Map<string, string>());
vi.mock('next/headers', () => ({
cookies: async () => ({
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name) } : undefined),
set: (name: string, value: string) => { jar.set(name, value); },
delete: (name: string) => { jar.delete(name); },
}),
}));
let dir: string;
function post(body: unknown, headers: Record<string, string>): NextRequest {
return new NextRequest('https://mail.example.org/api/admin/devices', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json', ...headers },
});
}
function audit(): string {
try {
return readFileSync(path.join(dir, 'audit.log'), 'utf8');
} catch {
return '';
}
}
describe('/api/admin/devices with the real admin check', () => {
let route: typeof import('../route');
let registry: typeof import('@/lib/devices/registry');
let row: { id: string };
beforeEach(async () => {
vi.resetModules();
dir = mkdtempSync(path.join(tmpdir(), 'bw-device-admin-'));
vi.stubEnv('ADMIN_STATE_DIR', dir);
vi.stubEnv('ADMIN_CONFIG_DIR', dir);
vi.stubEnv('SESSION_SECRET', 'x'.repeat(64));
jar.clear();
route = await import('../route');
registry = await import('@/lib/devices/registry');
row = await registry.registerDevice('alice@example.org', 'https://mail.example.org', { platform: 'x', appVersion: '1' });
const session = await import('@/lib/admin/session');
const { getAdminSessionGeneration } = await import('@/lib/admin/password');
jar.set('admin_session', session.createAdminSession(await getAdminSessionGeneration()));
});
afterEach(() => {
vi.unstubAllEnvs();
rmSync(dir, { recursive: true, force: true });
});
it.each([
['another site says so', { 'sec-fetch-site': 'cross-site' }],
['a page on another origin', { origin: 'https://evil.example.net' }],
])('refuses a wipe with the admin\'s own cookie when %s', async (_name, headers) => {
const response = await route.POST(post({ action: 'wipe', id: row.id }, headers));
expect(response.status).toBe(403);
expect((await registry.getDevice(row.id))?.wipe).toBeNull();
expect(audit()).toBe('');
});
it('refuses wiping a whole user from another site', async () => {
const response = await route.POST(post({ action: 'wipe-user', userKey: row.id.split('/')[0] }, { 'sec-fetch-site': 'cross-site' }));
expect(response.status).toBe(403);
expect((await registry.getDevice(row.id))?.wipe).toBeNull();
});
it('takes it from the same origin with the cookie', async () => {
const response = await route.POST(post({ action: 'wipe', id: row.id }, { 'sec-fetch-site': 'same-origin' }));
expect(response.status).toBe(200);
expect((await registry.getDevice(row.id))?.wipe).toMatchObject({ by: 'admin' });
expect(audit()).toContain('device.wipe.request');
});
it('refuses without the cookie, same origin or not', async () => {
jar.clear();
const response = await route.POST(post({ action: 'wipe', id: row.id }, { 'sec-fetch-site': 'same-origin' }));
expect(response.status).toBe(401);
expect((await registry.getDevice(row.id))?.wipe).toBeNull();
});
});
+114
View File
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditLog } from '@/lib/admin/audit';
import { getClientIP, requireAdminAuth } from '@/lib/admin/session';
import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response';
import {
cancelDeviceWipe,
DEVICE_ID_RE,
forgetDevice,
getDevice,
isUserKey,
listDevices,
requestDeviceWipe,
requestUserWipe,
} from '@/lib/devices/registry';
import { AUDIT_DEVICE_FORGET, AUDIT_DEVICE_WIPE_CANCEL, AUDIT_DEVICE_WIPE_REQUEST, type DeviceView } from '@/lib/devices/types';
import { logger } from '@/lib/logger';
const MAX_PAGE = 200;
function positiveInt(raw: string | null, fallback: number): number {
const parsed = Number.parseInt(raw ?? '', 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}
function auditDetail(device: DeviceView, by: string): Record<string, unknown> {
return { device: device.id, user: device.user, server: device.server, platform: device.platform, by };
}
/** Nothing to do: the row as it stands, so a stale page catches up. Only a missing row is an error. */
async function unchanged(id: string): Promise<NextResponse> {
const device = await getDevice(id);
return device ? NextResponse.json({ device }) : NextResponse.json({ error: 'not found' }, { status: 404 });
}
/** GET /api/admin/devices?offset=&limit=&q= : the registered devices, a page at a time. */
export async function GET(request: NextRequest) {
try {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const { searchParams } = request.nextUrl;
const page = await listDevices({
offset: positiveInt(searchParams.get('offset'), 0),
limit: Math.min(positiveInt(searchParams.get('limit'), 100) || 100, MAX_PAGE),
query: searchParams.get('q') ?? undefined,
});
return NextResponse.json(page, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
const unavailable = storeUnavailableResponse(error);
if (unavailable) return unavailable;
logger.error('admin devices GET', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/admin/devices
* { action: 'wipe' | 'cancel' | 'forget', id }
* { action: 'wipe-user', userKey }
*
* A wipe waits until the device next reaches this instance. Each request and
* each confirmed wipe goes in the audit log. Someone who controls the instance
* can wipe any device that checks in here: that's the admin's power, on the record.
*/
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
// The admin session has a role and no name.
const by = 'admin';
const ip = getClientIP(request);
const body = await request.json().catch(() => null) as { action?: unknown; id?: unknown; userKey?: unknown } | null;
const action = body?.action;
if (action === 'wipe-user') {
if (!isUserKey(body?.userKey)) return NextResponse.json({ error: 'invalid userKey' }, { status: 400 });
const devices = await requestUserWipe(body.userKey, by);
for (const device of devices) {
if (device.wipe) await auditLog(AUDIT_DEVICE_WIPE_REQUEST, auditDetail(device, by), ip);
}
return NextResponse.json({ devices });
}
if (typeof body?.id !== 'string' || !DEVICE_ID_RE.test(body.id)) {
return NextResponse.json({ error: 'invalid id' }, { status: 400 });
}
if (action === 'wipe') {
const device = await requestDeviceWipe(body.id, by);
if (!device) return unchanged(body.id);
await auditLog(AUDIT_DEVICE_WIPE_REQUEST, auditDetail(device, by), ip);
return NextResponse.json({ device });
}
if (action === 'cancel') {
const device = await cancelDeviceWipe(body.id);
if (!device) return unchanged(body.id);
await auditLog(AUDIT_DEVICE_WIPE_CANCEL, auditDetail(device, by), ip);
return NextResponse.json({ device });
}
if (action === 'forget') {
const device = await getDevice(body.id);
if (!device) return NextResponse.json({ error: 'not found' }, { status: 404 });
await forgetDevice(body.id);
await auditLog(AUDIT_DEVICE_FORGET, auditDetail(device, by), ip);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
} catch (error) {
const unavailable = storeUnavailableResponse(error);
if (unavailable) return unavailable;
logger.error('admin devices POST', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+320
View File
@@ -0,0 +1,320 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { NextRequest } from 'next/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const creds = vi.hoisted(() => ({ value: null as null | { accountName: string; serverUrl: string } }));
vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => creds.value }));
const admin = vi.hoisted(() => ({ allowed: true }));
// Rate limits are per address and outlive a test: each test is its own address.
const client = vi.hoisted(() => ({ ip: '', count: 0 }));
vi.mock('@/lib/admin/session', async (original) => ({
...await original<typeof import('@/lib/admin/session')>(),
requireAdminAuth: async () => (admin.allowed
? { session: { role: 'admin' } }
: { error: new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) }),
getClientIP: () => client.ip,
}));
import { POST as checkIn, REGISTER_PER_HOUR } from '../route';
import { POST as wiped } from '../wiped/route';
import { GET as adminList, POST as adminAct } from '../../admin/devices/route';
import * as registry from '@/lib/devices/registry';
let dir: string;
function request(url: string, body: unknown, headers: Record<string, string> = {}): NextRequest {
return new NextRequest(`https://mail.example.org${url}`, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json', 'sec-fetch-site': 'same-origin', ...headers },
});
}
function audit(): Array<{ action: string; detail: Record<string, unknown>; ip: string }> {
try {
return readFileSync(path.join(dir, 'audit.log'), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
} catch {
return [];
}
}
async function register(): Promise<{ id: string; secret: string }> {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const response = await checkIn(request('/api/devices', { platform: 'Chrome, Windows', appVersion: '1.0.0' }, { 'X-JMAP-Cookie-Slot': '0' }));
expect(response.status).toBe(200);
const body = await response.json() as { id: string; secret: string };
// Everything after this works with no session at all.
creds.value = null;
return body;
}
vi.setConfig({ testTimeout: 60_000 });
beforeEach(() => {
client.count += 1;
client.ip = `203.0.113.${client.count}`;
dir = mkdtempSync(path.join(tmpdir(), 'bw-device-routes-'));
vi.stubEnv('ADMIN_STATE_DIR', dir);
admin.allowed = true;
creds.value = null;
});
afterEach(() => {
vi.unstubAllEnvs();
rmSync(dir, { recursive: true, force: true });
});
describe('POST /api/devices', () => {
it('won\'t register anyone without a session cookie', async () => {
const response = await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }));
expect(response.status).toBe(401);
expect((await registry.listDevices()).total).toBe(0);
});
it('keys a new row on the cookie\'s account name', async () => {
const { id } = await register();
expect(id.split('/')[0]).toBe((await registry.listDevices()).devices[0].userKey);
expect((await registry.getDevice(id))?.user).toBe('alice@example.org');
});
it('refuses another site', async () => {
const response = await checkIn(request('/api/devices', { id: 'x', secret: 'y' }, { 'sec-fetch-site': 'cross-site' }));
expect(response.status).toBe(403);
});
it('tells a device with no cookies about its wipe, going by its secret', async () => {
const { id, secret } = await register();
const before = await checkIn(request('/api/devices', { id, secret, platform: 'x', appVersion: '1' }));
expect(await before.json()).toEqual({ wipe: null });
await registry.requestDeviceWipe(id, 'admin');
const after = await checkIn(request('/api/devices', { id, secret, platform: 'x', appVersion: '1' }));
expect((await after.json() as { wipe: unknown }).wipe).toMatchObject({ requestedAt: expect.any(String) });
});
it('says the same for a wrong secret as for a row that isn\'t there', async () => {
const { id } = await register();
const wrong = await checkIn(request('/api/devices', { id, secret: 'f'.repeat(64) }));
const missing = await checkIn(request('/api/devices', { id: `${'a'.repeat(64)}/${'b'.repeat(32)}`, secret: 'f'.repeat(64) }));
expect(wrong.status).toBe(404);
expect(await wrong.json()).toEqual(await missing.json());
});
it('tells the device whose row was wiped, so it can start over', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
await registry.confirmDeviceWipe(id, secret, 'admin');
const answer = await checkIn(request('/api/devices', { id, secret, platform: 'x', appVersion: '1' }));
expect(await answer.json()).toEqual({ wipe: null, wiped: true });
});
it('gives a new row\'s owner with the row, so the device can check it', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const response = await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }));
expect(await response.json()).toMatchObject({ user: 'alice@example.org', server: 'https://mail.example.org' });
});
it('holds new rows to a much lower limit per address than check-ins, and a 429 changes nothing', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const statuses: number[] = [];
for (let i = 0; i <= REGISTER_PER_HOUR; i += 1) {
statuses.push((await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }))).status);
}
expect(statuses.slice(0, REGISTER_PER_HOUR).every((status) => status === 200)).toBe(true);
expect(statuses.at(-1)).toBe(429);
expect((await registry.listDevices()).total).toBe(REGISTER_PER_HOUR);
// A device that's already registered still hears about its wipe.
const [{ id }] = (await registry.listDevices()).devices;
await registry.requestDeviceWipe(id, 'admin');
const answer = await checkIn(request('/api/devices', { id, secret: 'f'.repeat(64) }));
expect(answer.status).toBe(404);
});
it('turns down a new row past the cap when every row has a wipe waiting', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const spy = vi.spyOn(registry, 'registerDevice').mockRejectedValueOnce(new registry.TooManyDevicesError());
const response = await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }));
spy.mockRestore();
expect(response.status).toBe(429);
});
it('won\'t store a platform with control characters or at any length', async () => {
creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' };
const response = await checkIn(request('/api/devices', { platform: `a\nb${'x'.repeat(200)}`, appVersion: '1' }));
const { id } = await response.json() as { id: string };
const device = await registry.getDevice(id);
expect(device?.platform).not.toContain('\n');
expect(device?.platform.length).toBeLessThanOrEqual(64);
});
});
describe('POST /api/devices/wiped', () => {
it('confirms with the secret alone and writes an audit entry', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
const response = await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: 2 }));
expect(response.status).toBe(200);
const device = await registry.getDevice(id);
expect(device?.wipe).toBeNull();
expect(device?.wipedAt).not.toBeNull();
const entry = audit().find((line) => line.action === 'device.wipe.confirm');
expect(entry?.detail).toMatchObject({ device: id, user: 'alice@example.org', reason: 'admin', unsentDeleted: 2 });
expect(entry?.ip).toBe(client.ip);
});
it('sent again, writes no second entry: a row that didn\'t change leaves no trace', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
for (let i = 0; i < 3; i += 1) {
expect((await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: 2 }))).status).toBe(200);
}
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toHaveLength(1);
});
it('won\'t let any signed-in user fill the log: repeating a user wipe writes one entry', async () => {
const { id, secret } = await register();
for (let i = 0; i < 3; i += 1) await wiped(request('/api/devices/wiped', { id, secret, reason: 'user', unsent: 0 }));
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toHaveLength(1);
});
it('refuses reason admin when no wipe was asked, changes nothing and writes nothing', async () => {
const { id, secret } = await register();
const response = await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: 0 }));
expect(response.status).toBe(409);
expect(await response.json()).toEqual({ error: 'no_wipe_asked' });
expect((await registry.getDevice(id))?.wipedAt).toBeNull();
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toEqual([]);
});
it('turns down a reason it doesn\'t know instead of calling it the admin\'s', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
expect((await wiped(request('/api/devices/wiped', { id, secret, reason: 'mischief' }))).status).toBe(400);
expect((await registry.getDevice(id))?.wipe).not.toBeNull();
});
it('records an unknown count as unknown, not as none', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
await wiped(request('/api/devices/wiped', { id, secret, reason: 'admin', unsent: null }));
expect(audit().find((line) => line.action === 'device.wipe.confirm')?.detail).toMatchObject({ unsentDeleted: 'unknown' });
});
it('a sign-out drops the row with its secret, and writes nothing', async () => {
const { id, secret } = await register();
expect((await wiped(request('/api/devices/wiped', { id, secret, drop: true }))).status).toBe(200);
expect(await registry.getDevice(id)).toBeNull();
expect(audit()).toEqual([]);
});
it('a sign-out with a wipe waiting answers that wipe: the row stays, confirmed and on the record', async () => {
const { id, secret } = await register();
await registry.requestDeviceWipe(id, 'admin');
expect((await wiped(request('/api/devices/wiped', { id, secret, drop: true }))).status).toBe(200);
const device = await registry.getDevice(id);
expect(device?.wipe).toBeNull();
expect(device?.wipedAt).not.toBeNull();
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toHaveLength(1);
});
it('won\'t drop a row without the right secret', async () => {
const { id } = await register();
expect((await wiped(request('/api/devices/wiped', { id, secret: 'f'.repeat(64), drop: true }))).status).toBe(404);
expect(await registry.getDevice(id)).not.toBeNull();
});
it('answers 429 past the limit and takes nothing', async () => {
const { id } = await register();
await registry.requestDeviceWipe(id, 'admin');
let last = 200;
for (let i = 0; i <= 300 && last !== 429; i += 1) {
last = (await wiped(request('/api/devices/wiped', { id, secret: 'f'.repeat(64), reason: 'admin' }))).status;
}
expect(last).toBe(429);
expect((await registry.getDevice(id))?.wipe).not.toBeNull();
});
it('takes nothing without the right secret and writes no entry', async () => {
const { id } = await register();
await registry.requestDeviceWipe(id, 'admin');
const response = await wiped(request('/api/devices/wiped', { id, secret: 'f'.repeat(64), reason: 'admin' }));
expect(response.status).toBe(404);
expect((await registry.getDevice(id))?.wipe).not.toBeNull();
expect(audit().filter((line) => line.action === 'device.wipe.confirm')).toEqual([]);
});
});
describe('/api/admin/devices', () => {
it('is closed without an admin session', async () => {
admin.allowed = false;
expect((await adminAct(request('/api/admin/devices', { action: 'wipe', id: 'x' }))).status).toBe(401);
expect((await adminList(new NextRequest('https://mail.example.org/api/admin/devices'))).status).toBe(401);
});
it('wipes one device and writes an audit entry for the request', async () => {
const { id } = await register();
const response = await adminAct(request('/api/admin/devices', { action: 'wipe', id }));
expect(response.status).toBe(200);
expect((await registry.getDevice(id))?.wipe).toMatchObject({ by: 'admin' });
expect(audit().map((line) => line.action)).toEqual(['device.wipe.request']);
expect(audit()[0].detail).toMatchObject({ device: id, user: 'alice@example.org', by: 'admin' });
});
it('asking again writes no second entry', async () => {
const { id } = await register();
await adminAct(request('/api/admin/devices', { action: 'wipe', id }));
const again = await adminAct(request('/api/admin/devices', { action: 'wipe', id }));
expect(again.status).toBe(200);
expect(audit().filter((line) => line.action === 'device.wipe.request')).toHaveLength(1);
});
it('wipes all of a user\'s devices, one audit entry each', async () => {
const first = await register();
const second = await register();
const response = await adminAct(request('/api/admin/devices', { action: 'wipe-user', userKey: first.id.split('/')[0] }));
expect(response.status).toBe(200);
expect((await registry.getDevice(first.id))?.wipe).not.toBeNull();
expect((await registry.getDevice(second.id))?.wipe).not.toBeNull();
expect(audit().filter((line) => line.action === 'device.wipe.request')).toHaveLength(2);
});
it('takes a wipe back and forgets a row, on the record', async () => {
const { id } = await register();
await adminAct(request('/api/admin/devices', { action: 'wipe', id }));
await adminAct(request('/api/admin/devices', { action: 'cancel', id }));
expect((await registry.getDevice(id))?.wipe).toBeNull();
await adminAct(request('/api/admin/devices', { action: 'forget', id }));
expect(await registry.getDevice(id)).toBeNull();
expect(audit().map((line) => line.action)).toEqual(['device.wipe.request', 'device.wipe.cancel', 'device.forget']);
});
it('lists without any secret', async () => {
const { secret } = await register();
const response = await adminList(new NextRequest('https://mail.example.org/api/admin/devices'));
const text = await response.text();
expect(JSON.parse(text).total).toBe(1);
expect(text).not.toContain(secret);
expect(text).not.toContain('secretHash');
});
it('turns down nonsense', async () => {
expect((await adminAct(request('/api/admin/devices', { action: 'wipe', id: '../etc/passwd' }))).status).toBe(400);
expect((await adminAct(request('/api/admin/devices', { action: 'wipe-user', userKey: 'nope' }))).status).toBe(400);
expect((await adminAct(request('/api/admin/devices', { action: 'wipe', id: `${'a'.repeat(64)}/${'b'.repeat(32)}` }))).status).toBe(404);
});
});
describe('the audit log page', () => {
it('can filter by every device action', () => {
const page = readFileSync(path.join(process.cwd(), 'app/(main)/admin/_pages/logs.tsx'), 'utf8');
for (const action of ['device.wipe.request', 'device.wipe.confirm', 'device.wipe.cancel', 'device.forget']) {
expect(page).toContain(`value="${action}"`);
}
});
});
+86
View File
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { getClientIP } from '@/lib/admin/session';
import { checkInDevice, DEVICE_ID_RE, DEVICE_SECRET_RE, registerDevice, TooManyDevicesError } from '@/lib/devices/registry';
import { logger } from '@/lib/logger';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStore } from '@/lib/shared-store';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { isStoreUnavailable } from '@/lib/stored-data-errors';
const NO_STORE = { 'Cache-Control': 'no-store' };
const MAX_BODY_BYTES = 2048;
// Per address: a whole office can sit behind one, checking in as it starts up.
const PER_MINUTE = 300;
/**
* A new row is written to the state dir and stays half a year, for anyone with a
* session. Enough for an office turning this on in one morning: a device turned
* away tries again in a quarter of an hour.
*/
export const REGISTER_PER_HOUR = 60;
function cleanText(value: unknown): string {
if (typeof value !== 'string') return '';
return value.replace(/[^\x20-\x7e]/g, '').trim().slice(0, 64);
}
/**
* POST /api/devices
*
* With `{ id, secret }`: a device checks in and hears whether a wipe waits for
* it. No session needed, and nothing goes to the mail server: a device whose
* password was revoked still has to learn about its wipe.
*
* Without them: a new device registers for the signed-in account in the sealed
* context cookie (the slot header picks which). That needs a live session
* cookie, so a browser that restarted registers once its session is restored.
*/
export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const limit = await getStore().counters.reserve([
{ key: `device-checkin:${getClientIP(request)}`, max: PER_MINUTE, windowMs: 60_000 },
]).catch(() => ({ ok: true }));
if (!limit.ok) return NextResponse.json({ error: 'rate limited' }, { status: 429, headers: NO_STORE });
try {
const text = await request.text();
if (text.length > MAX_BODY_BYTES) return NextResponse.json({ error: 'too large' }, { status: 413, headers: NO_STORE });
let body: Record<string, unknown>;
try {
body = JSON.parse(text) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
}
const info = { platform: cleanText(body?.platform), appVersion: cleanText(body?.appVersion) };
if (body?.id !== undefined || body?.secret !== undefined) {
if (typeof body.id !== 'string' || !DEVICE_ID_RE.test(body.id) || typeof body.secret !== 'string' || !DEVICE_SECRET_RE.test(body.secret)) {
return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
}
const device = await checkInDevice(body.id, body.secret, info);
if (!device) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
if (device.wipe) return NextResponse.json({ wipe: { requestedAt: device.wipe.requestedAt } }, { headers: NO_STORE });
// Its data went already. A device still holding this row starts over.
return NextResponse.json(device.wipedAt ? { wipe: null, wiped: true } : { wipe: null }, { headers: NO_STORE });
}
const creds = await getStalwartCredentials(request);
if (!creds) return NextResponse.json({ error: 'Not authenticated' }, { status: 401, headers: NO_STORE });
const newRows = await getStore().counters.reserve([
{ key: `device-register:${getClientIP(request)}`, max: REGISTER_PER_HOUR, windowMs: 60 * 60 * 1000 },
]).catch(() => ({ ok: true }));
if (!newRows.ok) return NextResponse.json({ error: 'rate limited' }, { status: 429, headers: NO_STORE });
const created = await registerDevice(creds.accountName, creds.serverUrl, info);
return NextResponse.json({ ...created, wipe: null }, { headers: NO_STORE });
} catch (error) {
if (error instanceof Error && error.name === new TooManyDevicesError().name) {
return NextResponse.json({ error: 'too_many_devices' }, { status: 429, headers: NO_STORE });
}
if (isStoreUnavailable(error)) {
return NextResponse.json({ error: 'Temporarily unavailable' }, { status: 503, headers: { ...NO_STORE, 'Retry-After': '30' } });
}
logger.error('device check-in', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
}
}
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditLog } from '@/lib/admin/audit';
import { getClientIP } from '@/lib/admin/session';
import { confirmDeviceWipe, DEVICE_ID_RE, DEVICE_SECRET_RE, dropDevice } from '@/lib/devices/registry';
import type { DeviceView } from '@/lib/devices/types';
import { AUDIT_DEVICE_WIPE_CONFIRM, WIPE_REASONS, type WipeReason } from '@/lib/devices/types';
import { logger } from '@/lib/logger';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStore } from '@/lib/shared-store';
import { isStoreUnavailable } from '@/lib/stored-data-errors';
const NO_STORE = { 'Cache-Control': 'no-store' };
const MAX_BODY_BYTES = 1024;
// Per address: a whole office can sit behind one, checking in as it starts up.
const PER_MINUTE = 300;
/**
* POST /api/devices/wiped: a device says its wipe is done. By then it has signed
* out or is about to, so its secret is the proof, not a session. Sent again at
* the next contact until it gets through, so it answers the same each time, and
* only a row that really changes goes in the log.
*
* With `drop`: the account signed out here, with its data, so the row goes too.
*/
export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
const limit = await getStore().counters.reserve([
{ key: `device-checkin:${getClientIP(request)}`, max: PER_MINUTE, windowMs: 60_000 },
]).catch(() => ({ ok: true }));
if (!limit.ok) return NextResponse.json({ error: 'rate limited' }, { status: 429, headers: NO_STORE });
try {
const text = await request.text();
if (text.length > MAX_BODY_BYTES) return NextResponse.json({ error: 'too large' }, { status: 413, headers: NO_STORE });
let body: Record<string, unknown>;
try {
body = JSON.parse(text) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
}
if (typeof body?.id !== 'string' || !DEVICE_ID_RE.test(body.id) || typeof body.secret !== 'string' || !DEVICE_SECRET_RE.test(body.secret)) {
return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
}
const write = (device: DeviceView, reason: WipeReason, unsentDeleted: number | 'unknown') => auditLog(AUDIT_DEVICE_WIPE_CONFIRM, {
device: device.id,
user: device.user,
server: device.server,
platform: device.platform,
reason,
unsentDeleted,
}, getClientIP(request));
if (body.drop === true) {
const dropped = await dropDevice(body.id, body.secret);
if (!dropped) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
if (dropped.confirmed) await write(dropped.confirmed, 'user', 'unknown');
return NextResponse.json({ ok: true }, { headers: NO_STORE });
}
if (!WIPE_REASONS.includes(body.reason as WipeReason)) return NextResponse.json({ error: 'bad request' }, { status: 400, headers: NO_STORE });
const reason = body.reason as WipeReason;
const unsent = typeof body.unsent === 'number' && Number.isFinite(body.unsent) && body.unsent >= 0 ? Math.floor(body.unsent) : 'unknown';
const answer = await confirmDeviceWipe(body.id, body.secret, reason);
if (!answer) return NextResponse.json({ error: 'unknown_device' }, { status: 404, headers: NO_STORE });
if (answer === 'no_wipe_asked') return NextResponse.json({ error: 'no_wipe_asked' }, { status: 409, headers: NO_STORE });
if (answer.changed) await write(answer.device, reason, unsent);
return NextResponse.json({ ok: true }, { headers: NO_STORE });
} catch (error) {
if (isStoreUnavailable(error)) {
return NextResponse.json({ error: 'Temporarily unavailable' }, { status: 503, headers: { ...NO_STORE, 'Retry-After': '30' } });
}
logger.error('device wipe confirmation', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
}
}
@@ -0,0 +1,70 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
// Deleting everything of one user, which only makes sense where the server and its
// files are on the device: the desktop app. On an instance the same request is a
// way to delete someone else's connected accounts, so it has to do nothing.
const deleted = vi.hoisted(() => [] as unknown[]);
const session = vi.hoisted(() => ({ signedIn: false }));
vi.mock('@/lib/stalwart/credentials', () => ({
getStalwartCredentials: async () => (session.signedIn ? { accountName: 'me@example.org', serverUrl: 'https://mail.example.org', authHeader: 'x' } : null),
}));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled: true } }) },
}));
vi.mock('@/lib/plugin-secrets/vault', () => ({
VaultFullError: class extends Error {},
deleteAllUserCredentials: vi.fn(async (user: unknown) => { deleted.push(user); }),
deleteUserCredentials: vi.fn(async () => {}),
listUserCredentials: vi.fn(async () => []),
putUserCredential: vi.fn(async () => {}),
}));
async function remove(query: string, headers: Record<string, string> = { 'sec-fetch-site': 'same-origin' }): Promise<Response> {
vi.resetModules();
const { DELETE } = await import('../route');
return DELETE(new NextRequest(`https://app.local/api/plugin-secrets?${query}`, { method: 'DELETE', headers }));
}
const EVICTED = 'all=true&username=alice&server=https%3A%2F%2Fmail.example.org';
beforeEach(() => {
deleted.length = 0;
session.signedIn = false;
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('DELETE /api/plugin-secrets?all=true', () => {
it('in the desktop app takes a dropped account by who it was, with no cookie', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await remove(EVICTED)).status).toBe(200);
expect(deleted).toEqual([{ username: 'alice', serverUrl: 'https://mail.example.org' }]);
});
it('won\'t take it from another site', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect((await remove(EVICTED, { 'sec-fetch-site': 'cross-site' })).status).toBe(403);
expect(deleted).toEqual([]);
});
it('on an instance deletes nobody\'s, with a cookie or without', async () => {
expect((await remove(EVICTED)).status).toBe(401);
session.signedIn = true;
await remove(EVICTED);
expect(deleted).toEqual([]);
});
it('in the desktop app with a cookie and no name takes the signed-in account', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
session.signedIn = true;
expect((await remove('all=true')).status).toBe(200);
expect(deleted).toEqual([{ username: 'me@example.org', serverUrl: 'https://mail.example.org' }]);
});
});
+28
View File
@@ -7,12 +7,14 @@ import { adminCredentialOrigins, adminCredentialStatus } from '@/lib/plugin-secr
import { declaredUserCredential } from '@/lib/plugin-secrets/known-plugins';
import {
VaultFullError,
deleteAllUserCredentials,
deleteUserCredentials,
listUserCredentials,
putUserCredential,
type VaultUser,
} from '@/lib/plugin-secrets/vault';
import { storedDataUnavailableResponse } from '@/lib/stored-data-errors';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const MAX_SECRET_LENGTH = 4096;
@@ -112,8 +114,34 @@ export async function POST(request: NextRequest) {
export async function DELETE(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
// An account dropped earlier, in the desktop app: no cookie left, and this server serves this device alone.
const { searchParams } = request.nextUrl;
const owner = searchParams.get('username');
const server = searchParams.get('server');
if (IS_DESKTOP_BUILD && searchParams.get('all') === 'true' && owner && server) {
try {
await deleteAllUserCredentials({ username: owner, serverUrl: server });
return NextResponse.json({ ok: true }, { headers: NO_STORE });
} catch (error) {
const unavailable = storedDataUnavailableResponse(error, NO_STORE);
if (unavailable) return unavailable;
throw error;
}
}
const user = await currentUser(request);
if (user instanceof NextResponse) return user;
// A wipe in the desktop app, where this server and its files are on the device.
// On an instance they aren't the device's to remove.
if (IS_DESKTOP_BUILD && request.nextUrl.searchParams.get('all') === 'true') {
try {
await deleteAllUserCredentials(user);
return NextResponse.json({ ok: true }, { headers: NO_STORE });
} catch (error) {
const unavailable = storedDataUnavailableResponse(error, NO_STORE);
if (unavailable) return unavailable;
throw error;
}
}
const pluginId = request.nextUrl.searchParams.get('plugin') ?? '';
const credentialId = request.nextUrl.searchParams.get('credential') ?? undefined;
if (!PLUGIN_ID_RE.test(pluginId)) return bad('Invalid plugin id');
@@ -59,6 +59,16 @@ describe('Offline mode section', () => {
expect((puts[0].organisation as { devices: { noOffline: boolean } }).devices.noOffline).toBe(true);
});
it('leaves a refused login\'s data alone by default, and can ask for deletion at once, per scope', async () => {
await renderSection();
const toggle = screen.getByTestId('org-devices-wipeOnRefusedLogin');
expect(toggle.getAttribute('aria-checked')).toBe('false');
act(() => latest!.setScopeId('field'));
fireEvent.click(screen.getByTestId('org-devices-wipeOnRefusedLogin'));
expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { wipeOnRefusedLogin: true } });
expect(latest!.policy?.organisation?.devices.wipeOnRefusedLogin).toBe(false);
});
it('writes a scope override only into that scope', async () => {
await renderSection();
act(() => latest!.setScopeId('field'));
@@ -50,6 +50,7 @@ const describeCap = (value: number, unit: string) => (value > 0 ? `${value} ${un
export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
const forbid = draft.orgValue('devices', 'noOffline');
const noAttachments = draft.orgValue('devices', 'noOfflineAttachments');
const wipeOnRefused = draft.orgValue('devices', 'wipeOnRefusedLogin');
return (
<SettingsSection
id="offline"
@@ -105,6 +106,15 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
<InheritNote draft={draft} own={noAttachments.own} everyone={noAttachments.everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'noOfflineAttachments')} />
</div>
</SettingItem>
<SettingItem
label="Delete a device's mail as soon as the server refuses its login"
description="Off: the device signs out and hides what it keeps, unsent messages included, until someone signs in with the new password, or for 30 days. That's what a password changed on another device needs. On: it's deleted right away, unsent messages included, and only that account's. A wipe you ask for below is immediate either way."
>
<div className="flex flex-col items-start gap-1 sm:items-end">
<ToggleSwitch checked={wipeOnRefused.value} onChange={(v) => draft.setOrgValue('devices', 'wipeOnRefusedLogin', v)} testId="org-devices-wipeOnRefusedLogin" />
<InheritNote draft={draft} own={wipeOnRefused.own} everyone={wipeOnRefused.everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'wipeOnRefusedLogin')} />
</div>
</SettingItem>
<p className="pt-2 text-xs text-muted-foreground">
What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything),
with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not
@@ -46,6 +46,29 @@ describe('Settings > Offline', () => {
}
});
it('offers removing everything where offline mode exists', () => {
render(<OfflineSettings />);
expect(screen.getByTestId('wipe-device-section')).toBeTruthy();
});
it('doesn\'t where it can\'t be used: a private window, a store that isn\'t there, an organisation\'s no', () => {
useOfflineStore.setState({ storageBlocked: true });
const privateWindow = render(<OfflineSettings />);
expect(screen.queryByTestId('wipe-device-section')).toBeNull();
privateWindow.unmount();
useOfflineStore.setState({ storageBlocked: false });
setStoreBackend({ supported: () => false } as unknown as StoreBackend);
const noStore = render(<OfflineSettings />);
expect(screen.queryByTestId('wipe-device-section')).toBeNull();
noStore.unmount();
setStoreBackend({ supported: () => true } as unknown as StoreBackend);
setNoOffline(true);
render(<OfflineSettings />);
expect(screen.queryByTestId('wipe-device-section')).toBeNull();
});
it('is off until the user turns it on in this browser', () => {
render(<OfflineSettings />);
const toggle = screen.getByTestId('offline-switch');
@@ -0,0 +1,57 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const wipe = vi.hoisted(() => ({ whole: vi.fn(async () => {}), unsent: 0 as number | null }));
vi.mock('@/lib/offline/device-wipe', () => ({ wipeWholeDevice: wipe.whole }));
vi.mock('@/lib/offline/offline-mode', () => ({ unsentForWipe: async () => wipe.unsent }));
import { WipeDeviceSection } from '../wipe-device-section';
beforeEach(() => {
wipe.whole.mockClear();
wipe.unsent = 0;
});
describe('the wipe in settings', () => {
it('says what goes before anything happens', async () => {
render(<WipeDeviceSection />);
fireEvent.click(screen.getByTestId('wipe-device-button'));
const details = await screen.findByTestId('wipe-device-details');
for (const key of ['confirm_mail', 'confirm_keys', 'confirm_plugins', 'confirm_files', 'confirm_templates', 'confirm_accounts']) {
expect(details.textContent).toContain(key);
}
expect(details.textContent).not.toContain('confirm_unsent');
expect(wipe.whole).not.toHaveBeenCalled();
});
it('names unsent mail when there is some', async () => {
wipe.unsent = 2;
render(<WipeDeviceSection />);
fireEvent.click(screen.getByTestId('wipe-device-button'));
expect((await screen.findByTestId('wipe-device-details')).textContent).toContain('confirm_unsent');
});
it('says it can\'t tell, not that there\'s nothing, when the count is unknown', async () => {
wipe.unsent = null;
render(<WipeDeviceSection />);
fireEvent.click(screen.getByTestId('wipe-device-button'));
const details = await screen.findByTestId('wipe-device-details');
expect(details.textContent).toContain('confirm_unsent_unknown');
});
it('wipes only after the yes, as the user\'s own', async () => {
render(<WipeDeviceSection />);
fireEvent.click(screen.getByTestId('wipe-device-button'));
fireEvent.click(await screen.findByRole('button', { name: 'confirm_button' }));
await waitFor(() => expect(wipe.whole).toHaveBeenCalledWith('user'));
});
it('a no leaves everything', async () => {
render(<WipeDeviceSection />);
fireEvent.click(screen.getByTestId('wipe-device-button'));
await screen.findByTestId('wipe-device-details');
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
await waitFor(() => expect(screen.queryByTestId('wipe-device-details')).toBeNull());
expect(wipe.whole).not.toHaveBeenCalled();
});
});
+37
View File
@@ -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;
}
+3 -1
View File
@@ -8,13 +8,14 @@ import { Button } from '@/components/ui/button';
import { IS_LITE } from '@/lib/lite';
import { isDesktopApp } from '@/lib/desktop/bridge';
import { signedInAccountIds } from '@/lib/device-policy';
import { offlineSupported, readPersistence, refreshUsage, requestPersistence, safariMayDelete } from '@/lib/offline/offline-mode';
import { offlineAllowed, offlineSupported, readPersistence, refreshUsage, requestPersistence, safariMayDelete } from '@/lib/offline/offline-mode';
import { useOfflineStore } from '@/lib/offline/state';
import { useDevicePolicy } from '@/hooks/use-device-policy';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { AccountStorage } from './account-storage';
import { OutboxList } from './outbox-list';
import { WipeDeviceSection } from './wipe-device-section';
/**
* Settings > Backup & reset > Offline (#523): for each account signed in here,
@@ -81,6 +82,7 @@ export function OfflineSettings() {
heading={several ? labels.find((entry) => entry.id === account)?.email || labels.find((entry) => entry.id === account)?.label || account : null}
/>
))}
{[active, ...accounts].some((account) => offlineAllowed(account)) && <WipeDeviceSection />}
</>
)}
<OutboxList />
@@ -0,0 +1,75 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { wipeWholeDevice } from '@/lib/offline/device-wipe';
import { unsentForWipe } from '@/lib/offline/offline-mode';
/** The question doesn't wait as long for the count as the wipe itself does. */
const QUESTION_WAIT_MS = 2_000;
/**
* "Remove everything from this device", for someone giving a computer away or
* selling it. It signs out, so the question says exactly what goes.
*/
export function WipeDeviceSection() {
const t = useTranslations('offlineWipe');
const [asking, setAsking] = useState(false);
// Null: the store couldn't say, which isn't the same as none.
const [unsent, setUnsent] = useState<number | null>(0);
const [wiping, setWiping] = useState(false);
const ask = async () => {
setUnsent(await unsentForWipe('all', QUESTION_WAIT_MS).catch(() => null));
setAsking(true);
};
const confirmed = () => {
setAsking(false);
setWiping(true);
void wipeWholeDevice('user').finally(() => setWiping(false));
};
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border py-3" data-testid="wipe-device-section">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{t('settings_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('settings_description')}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="text-destructive"
disabled={wiping}
onClick={() => { void ask(); }}
data-testid="wipe-device-button"
>
{wiping ? t('settings_wiping') : t('settings_button')}
</Button>
<ConfirmDialog
isOpen={asking}
onClose={() => setAsking(false)}
onConfirm={confirmed}
title={t('confirm_title')}
message={t('confirm_message')}
details={(
<ul className="list-disc space-y-1 ps-5 text-sm text-muted-foreground" data-testid="wipe-device-details">
<li>{t('confirm_mail')}</li>
{unsent === null && <li className="font-medium text-foreground">{t('confirm_unsent_unknown')}</li>}
{unsent !== null && unsent > 0 && <li className="font-medium text-foreground">{t('confirm_unsent', { count: unsent })}</li>}
<li>{t('confirm_keys')}</li>
<li>{t('confirm_plugins')}</li>
<li>{t('confirm_files')}</li>
<li>{t('confirm_templates')}</li>
<li>{t('confirm_accounts')}</li>
</ul>
)}
confirmText={t('confirm_button')}
variant="destructive"
/>
</div>
);
}
+382
View File
@@ -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<Device[]> {
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<void> {
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<Device> {
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<void> {
const res = await admin.request.post('/api/admin/devices', { data: { action: 'wipe', id } });
expect(res.ok()).toBe(true);
}
async function enableOffline(page: Page): Promise<void> {
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<void> {
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<void> {
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<void> => {
for await (const handle of (dir as unknown as { values(): AsyncIterable<FileSystemHandle> }).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<number>((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<void> {
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<void> {
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<void>((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<string, unknown> }> }).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()]);
});
@@ -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<string, unknown>) => ({ 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 = {
+1
View File
@@ -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' },
],
},
{
+8
View File
@@ -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<Organisat
noOfflineAttachments: bool(dev.noOfflineAttachments),
offlineMaxAttachmentMb: count(dev.offlineMaxAttachmentMb, MAX_OFFLINE_MB),
offlineMaxTotalMb: count(dev.offlineMaxTotalMb, MAX_OFFLINE_MB),
wipeOnRefusedLogin: bool(dev.wipeOnRefusedLogin),
});
const cal = section('calendars');
+10 -4
View File
@@ -85,10 +85,16 @@ export function strictestOfAllScopes(policy: SettingsPolicy): ServerDevicePolicy
return out;
}
/** Everything on, and each cap as low as it goes: when not even the policy can be read. */
export const ALL_DEVICE_LIMITS: ServerDevicePolicy = Object.fromEntries(
Object.entries(DEFAULT_ORGANISATION_POLICY.devices).map(([key, off]) => [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
+139
View File
@@ -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, '<html>Not found</html>', 'text/html'],
['a 500', 500, JSON.stringify({ error: 'Internal server error' }), 'application/json'],
['a 503', 503, '<html>Bad gateway</html>', 'text/html'],
['a 200 from something else', 200, '<html>Login</html>', '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<string, string>)['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, '<html>');
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');
});
});
+249
View File
@@ -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<typeof fakeSharedStore>): Promise<Registry> {
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');
});
});
+102
View File
@@ -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<Record<string, unknown> | null> {
try {
const body = await response.clone().json() as unknown;
return body && typeof body === 'object' ? body as Record<string, unknown> : null;
} catch {
return null;
}
}
function post(path: string, body: unknown, signal?: AbortSignal, headers: Record<string, string> = {}): Promise<Response> {
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<RegisteredRow | null> {
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<DeviceRecord, 'id' | 'secret'>, signal?: AbortSignal): Promise<CheckInAnswer> {
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<boolean> {
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;
}
}
+285
View File
@@ -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<DeviceView, 'id' | 'userKey'> {
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<StoredDevice | null> {
return parse((await collection().get(id))?.data);
}
async function readMany(ids: string[]): Promise<Array<[string, StoredDevice]>> {
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<void> {
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<void> {
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<DeviceView | null> {
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<WipeConfirmation | null> {
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<DeviceView | null> {
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<DeviceView[]> {
// 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<DeviceView | null> {
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<boolean> {
return collection().delete(id);
}
export async function getDevice(id: string): Promise<DeviceView | null> {
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<DevicePage> {
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<Array<[string, StoredDevice]>> {
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;
}
+30
View File
@@ -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 {
/** `<settingsId(user, server)>/<deviceId>`. */
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';
+183
View File
@@ -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<T>(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<string, WipeReason> {
return readJson<Record<string, WipeReason>>(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<WipeConfirmation[]>(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<WipeNote | null>(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<WipeNote | null>(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<string, RefusedLogin> {
return readJson<Record<string, RefusedLogin>>(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<string, RefusedLogin>): 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<string, number> {
return readJson<Record<string, number>>(DATABASES_KEY, {});
}
export function writeBlockedDatabases(blocked: Record<string, number>): 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;
}
@@ -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('<html>Sign in to the proxy</html>', { 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<typeof vi.fn> } {
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);
});
});
+6
View File
@@ -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;
+42 -2
View File
@@ -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<string, string>, '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<string, string>, '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<void> {
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');
File diff suppressed because it is too large Load Diff
@@ -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([]);
});
});
+10 -8
View File
@@ -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<Listener>();
@@ -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.
}
+727
View File
@@ -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<string, Promise<void>>();
let lockQueue: Promise<unknown> = Promise.resolve();
/** One wipe at a time. Without Web Locks: at least in this window. */
function underWipeLock(work: () => Promise<void>): Promise<void> {
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<boolean> {
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<void> {
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<string, number> = {};
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<void> {
const bridge = getDesktopBridge() as unknown as {
os?: { clearSurfaces?: () => void };
clipboard?: { clearFiles?: () => void | Promise<void> };
} | 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<void> {
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<void> {
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<void> {
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<ServiceWorkerRegistration | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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<DeviceRecord, 'id' | 'secret'>): void {
queueWipeConfirmation({ id: row.id, secret: row.secret, reason: 'user', unsent: 0, drop: true });
}
async function dropRows(scope: string): Promise<void> {
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<string, number>();
const lastRegisterTry = new Map<string, number>();
/** 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<void> | null = null;
async function checkInRecord(record: DeviceRecord, now: number): Promise<void> {
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<void> {
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<void> {
if (checkInFlight) return checkInFlight;
checkInFlight = runCheckIns(force, now).finally(() => { checkInFlight = null; });
return checkInFlight;
}
async function runCheckIns(force: boolean, now: number): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
};
}
+23
View File
@@ -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<void> {
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);
}));
}
+40 -11
View File
@@ -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<typeof known>((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<number | null> {
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<typeof setTimeout> | 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<null>((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<number | null> {
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<void> {
if (named.has(scope) || named.has('all')) return;
@@ -1051,7 +1077,10 @@ export async function wipeAccount(account: string): Promise<void> {
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<void> {
export async function sweepAccountsGone(now = Date.now()): Promise<void> {
const evicted = readEvicted();
const accounts = useAccountStore.getState().accounts;
for (const account of useOfflineStore.getState().enabled) {
+23
View File
@@ -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<string, EvictedOwner> {
return readRecord<EvictedOwner>(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();
}
+39
View File
@@ -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<void>;
const cleaners = new Set<WipeCleaner>();
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<void> {
await Promise.all([...cleaners].map(async (cleaner) => {
let timer: ReturnType<typeof setTimeout> | 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);
}
}));
}
+5
View File
@@ -217,6 +217,11 @@ export function setUserCredentialSecret(
});
}
/** Everything this user connected, for every plugin. */
export async function deleteAllUserCredentials(user: VaultUser): Promise<void> {
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<void> {
return modify(user, (data) => {
+6
View File
@@ -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);
@@ -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 () => {
+2
View File
@@ -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<Record<string, number>> {
+2
View File
@@ -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),
];
+6 -2
View File
@@ -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 {
+49
View File
@@ -170,6 +170,55 @@ export async function forgetAccountKey(account: string): Promise<void> {
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<DeviceRecord | null> {
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<void> {
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<void> {
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<boolean> {
if (typeof indexedDB.databases !== 'function') return true;
return (await indexedDB.databases()).some((database) => database.name === DB_NAME);
}
export async function listDeviceRecords(onlyIfThere = true): Promise<DeviceRecord[]> {
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`);
+22
View File
@@ -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": "إلى أي مدة سابقة يُحتفظ بالبريد",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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": "تا چه مدت قبل نگه داشته شود",
+22
View File
@@ -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",
+22
View File
@@ -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": "כמה זמן אחורה לשמור",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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": "保存する期間",
+22
View File
@@ -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": "보관할 기간",
+22
View File
@@ -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",
+22
View File
@@ -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": "Хэр удаан хугацаагаар хадгалах вэ",
+22
View File
@@ -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",
+22
View File
@@ -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",
+22
View File
@@ -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ć",
+22
View File
@@ -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",
+22
View File
@@ -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ă",
+22
View File
@@ -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": "За какой период хранить",
+22
View File
@@ -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ť",
+22
View File
@@ -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": "เก็บย้อนหลังนานแค่ไหน",
+22
View File
@@ -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",
+22
View File
@@ -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": "За який період зберігати",
+22
View File
@@ -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",
+22
View File
@@ -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": "保留多久以內的內容",
+22
View File
@@ -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": "保留多久以内的内容",
+2
View File
@@ -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
@@ -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<void>,
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: () => '<x@y>' };
});
const order = vi.hoisted(() => [] as string[]);
const gate = vi.hoisted(() => ({ run: (async () => {}) as () => Promise<void> }));
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<string[]> {
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<void> {
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');
});
});
});
+12 -9
View File
@@ -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([]);
});
});
+7 -3
View File
@@ -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<AccountState>()(
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<AccountState>()(
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);
+114 -31
View File
@@ -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<string>();
const endingRefused = new Set<string>();
/** 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<void> {
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<AuthState>) => 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<boolean> {
async function loginRefused(res: Response): Promise<boolean> {
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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
});
// 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<AuthState>()(
clearAllStores();
}
clients.set(accountId, client);
holdClient(accountId, client);
bindClientStatusHandlers(client, set, get, accountId);
accountStore.addAccount({
@@ -2211,7 +2274,7 @@ export const useAuthStore = create<AuthState>()(
clearAllStores();
}
clients.set(accountId, client);
holdClient(accountId, client);
bindClientStatusHandlers(client, set, get, accountId);
accountStore.addAccount({
@@ -2335,17 +2398,18 @@ export const useAuthStore = create<AuthState>()(
// 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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
}
} 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<AuthState>()(
}
// 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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
// 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<AuthState>()(
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<AuthState>()(
} 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<AuthState>()(
// 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<AuthState>()(
// 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<AuthState>()(
? 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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
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;
}
};