feat: hide an account's mail when its login is refused, until the new password or 30 days

A refused login used to delete the account's data, and with it everyone's
S/MIME and OpenPGP keys, also with offline off. The usual cause is a password
changed on another device, which Stalwart 0.16 and 1.0 both answer with
`invalid_grant`, so that lost unsent mail for little protection.

- A refused login signs out at once and hides the data. A new password brings
  it back, unsent mail included. Otherwise it goes after 30 days. The sign-in
  page gives a count and a date. The 24-hour timer is gone.
- `organisation.devices.wipeOnRefusedLogin`, off by default, asks for deletion
  at once, of that account's own data only.
- The session limit no longer counts as a refusal on any route. It hides the
  data and says nothing.
- A wipe runs under a Web Lock, tells other windows when it starts and ends,
  and queues its report before deleting anything. It reports only once the
  deletion worked and no plugin database is left.
- More goes with a wipe: Pro tabs, search history, Files and WebDAV state,
  templates, pop-out drafts, shown notifications, push state, and Web Push
  with the last account.
- The unsent count is per account, and unknown rather than 0 when the store
  can't say. A new row is checked against the account that asked, and a
  sign-out drops its row.

Existing tests changed because the rule did: `auth-store-keep-local.test.ts`
expected a wipe on `invalid_grant` and at the session limit, and
`device-wipe.test.ts` expected shared keys to go on a refused login.
This commit is contained in:
Linus Rath
2026-11-03 15:11:47 +01:00
parent d1227bfac6
commit 940a572844
60 changed files with 1827 additions and 325 deletions
+18 -4
View File
@@ -12,7 +12,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 } from "@/lib/devices/wipe-state";
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";
@@ -285,10 +286,15 @@ function LoginPageContent() {
// 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<number | null>(null);
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()?.unsent ?? null);
setWipeLost(consumeWipeNote());
const read = () => setRefusedWaiting(refusedWaits());
read();
return onRefusedLoginsChanged(read);
}, []);
const [expiredDraftsUnsaved, setExpiredDraftsUnsaved] = useState(false);
const [showPassword, setShowPassword] = useState(false);
@@ -1462,9 +1468,17 @@ function LoginPageContent() {
<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">
{wipeLost > 0 ? tOfflineWipe("note_unsent", { count: wipeLost }) : tOfflineWipe("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,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' }]);
});
});
+14
View File
@@ -114,6 +114,20 @@ 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.
@@ -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');
@@ -1,9 +1,9 @@
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 }));
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', () => ({ unsentCount: async () => ({ messages: wipe.unsent, changes: 0 }) }));
vi.mock('@/lib/offline/offline-mode', () => ({ unsentForWipe: async () => wipe.unsent }));
import { WipeDeviceSection } from '../wipe-device-section';
@@ -17,7 +17,7 @@ describe('the wipe in settings', () => {
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_accounts']) {
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');
@@ -31,6 +31,14 @@ describe('the wipe in settings', () => {
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'));
+8 -2
View File
@@ -2,11 +2,17 @@
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { consumeWipeNote } from '@/lib/devices/wipe-state';
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.
@@ -24,7 +30,7 @@ export function DeviceWipeHost() {
useEffect(() => {
if (!signedIn) return;
const note = consumeWipeNote();
if (note) toast.warning(note.unsent > 0 ? tRef.current('note_unsent', { count: note.unsent }) : tRef.current('note'), { duration: 0 });
if (note) toast.warning(wipeNoteText(note, tRef.current), { duration: 0 });
}, [signedIn]);
return null;
+2 -2
View File
@@ -8,7 +8,7 @@ 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';
@@ -82,10 +82,10 @@ 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 />
<WipeDeviceSection />
</SettingsSection>
);
}
+10 -5
View File
@@ -5,7 +5,10 @@ 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 { unsentCount } from '@/lib/offline/offline-mode';
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
@@ -14,12 +17,12 @@ import { unsentCount } from '@/lib/offline/offline-mode';
export function WipeDeviceSection() {
const t = useTranslations('offlineWipe');
const [asking, setAsking] = useState(false);
const [unsent, setUnsent] = useState(0);
// 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 () => {
const { messages } = await unsentCount('all').catch(() => ({ messages: 0 }));
setUnsent(messages);
setUnsent(await unsentForWipe('all', QUESTION_WAIT_MS).catch(() => null));
setAsking(true);
};
@@ -55,10 +58,12 @@ export function WipeDeviceSection() {
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 > 0 && <li className="font-medium text-foreground">{t('confirm_unsent', { count: unsent })}</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>
)}
@@ -354,6 +354,24 @@ test('the user removes everything from this device, from settings', async ({ bro
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);
@@ -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 = {
+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
+47 -2
View File
@@ -40,6 +40,16 @@ describe('a check-in', () => {
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');
@@ -48,13 +58,24 @@ describe('a check-in', () => {
describe('registering', () => {
it('names the account\'s cookie slot', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify(record), { status: 200 }));
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(record);
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();
@@ -81,6 +102,30 @@ describe('reporting a wipe', () => {
});
});
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' };
+18 -6
View File
@@ -10,7 +10,15 @@ import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DeviceRecord } from '@/lib/store/client/keys';
import type { WipeConfirmation } from './wipe-state';
export type CheckInAnswer = 'wipe' | 'clear' | 'forgotten' | 'unavailable';
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 {
@@ -55,12 +63,13 @@ function post(path: string, body: unknown, signal?: AbortSignal, headers: Record
}
/** 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<{ id: string; secret: string } | null> {
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);
return typeof body?.id === 'string' && typeof body.secret === 'string' ? { id: body.id, secret: body.secret } : null;
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;
}
@@ -72,7 +81,8 @@ export async function checkInWithInstance(record: Pick<DeviceRecord, 'id' | 'sec
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';
return body.wipe && typeof body.wipe === 'object' ? 'wipe' : 'clear';
if (body.wipe && typeof body.wipe === 'object') return 'wipe';
return body.wiped === true ? 'wiped' : 'clear';
} catch {
return 'unavailable';
}
@@ -81,9 +91,11 @@ export async function checkInWithInstance(record: Pick<DeviceRecord, 'id' | 'sec
/** 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 response = await post('/api/devices/wiped', entry, signal);
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;
return response.status === 404 && (await readJson(response))?.error === 'unknown_device';
const error = (await readJson(response))?.error;
return (response.status === 404 && error === 'unknown_device') || (response.status === 409 && error === 'no_wipe_asked');
} catch {
return false;
}
+70 -22
View File
@@ -4,6 +4,7 @@
* 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';
@@ -21,14 +22,6 @@ export const WHOLE_DEVICE = '*';
*/
export const CREDENTIALS_REFUSED_CONFIRM_MS = 60_000;
/**
* How long a refused login's data waits for a sign-in with the new password.
* Nothing but this 401 says the login is over, since a password login has no
* `invalid_grant`. A server being restored from a backup says the same for hours,
* and someone who changed their password elsewhere is about to type the new one.
*/
export const REFUSED_KEEP_MS = 24 * 60 * 60 * 1000;
function readJson<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
@@ -69,7 +62,12 @@ export interface WipeConfirmation {
id: string;
secret: string;
reason: WipeReason;
unsent: number;
/** 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[] {
@@ -88,32 +86,83 @@ export function dropWipeConfirmation(id: string): void {
// ---- The note at the next sign-in: a count, never subjects ----
export function addWipeNote(unsent: number): void {
const before = readJson<{ unsent?: number }>(NOTE_KEY, {});
writeJson(NOTE_KEY, { unsent: (before.unsent ?? 0) + unsent }, false);
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(): { unsent: number } | null {
const note = readJson<{ unsent?: number } | null>(NOTE_KEY, null);
export function consumeWipeNote(): WipeNote | null {
const note = readJson<WipeNote | null>(NOTE_KEY, null);
writeJson(NOTE_KEY, null, true);
return note && typeof note.unsent === 'number' ? { unsent: note.unsent } : null;
if (!note || typeof note.unsent !== 'number') return null;
return note.unknown ? { unsent: note.unsent, unknown: true } : { unsent: note.unsent };
}
// ---- A refused password: when the wait started ----
// ---- A refused login: what waits for the new password. A count, never subjects ----
export function readRefusedLogins(): Record<string, number> {
return readJson<Record<string, number>>(REFUSED_KEY, {});
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 markLoginRefused(account: string, now = Date.now()): void {
writeJson(REFUSED_KEY, { ...readRefusedLogins(), [account]: now }, false);
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];
writeJson(REFUSED_KEY, refused, Object.keys(refused).length === 0);
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 ----
@@ -130,6 +179,5 @@ export function writeBlockedDatabases(blocked: Record<string, number>): void {
export function wipeWorkWaiting(): boolean {
return Object.keys(readPendingWipes()).length > 0
|| readWipeConfirmations().length > 0
|| Object.keys(readRefusedLogins()).length > 0
|| Object.keys(readBlockedDatabases()).length > 0;
}
+587 -60
View File
@@ -1,15 +1,23 @@
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// What a wipe removes and what makes one happen. Real auth store, real offline
// mode, the store's real engine, real IndexedDB (fake-indexeddb): only the
// network is a stub, and the OPFS pool, which jsdom doesn't have.
vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })) }));
const purge = vi.hoisted(() => ({ files: 0 }));
const purge = vi.hoisted(() => ({ files: 0, fail: false, during: null as null | (() => void) }));
vi.mock('@/lib/store/client/purge', async () => {
const keys = await vi.importActual<typeof import('@/lib/store/client/keys')>('@/lib/store/client/keys');
return { purgeStoreFiles: async () => { purge.files += 1; keys.deleteKeyDatabase(); }, deleteStorePool: async () => {} };
return {
purgeStoreFiles: async () => {
purge.during?.();
if (purge.fail) throw new Error('The files are held');
purge.files += 1;
keys.deleteKeyDatabase();
},
deleteStorePool: async () => {},
};
});
// A store and a full sign-out in each test: slow on a machine doing other work.
@@ -38,6 +46,8 @@ let sent: Array<{ url: string; body: Record<string, unknown>; headers: Record<st
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
const noWipe = () => json(200, { wipe: null });
/** What the route gives a new row. */
const newRow = (id = ROW) => json(200, { id, secret: SECRET, user: 'alice', server: 'https://mail.example.test' });
const wipeNow = () => json(200, { wipe: { requestedAt: '2026-11-03T00:00:00.000Z' } });
function entry(id: string, cookieSlot: number) {
@@ -69,6 +79,8 @@ async function freshIndexedDb(): Promise<void> {
beforeEach(async () => {
vi.resetModules();
purge.files = 0;
purge.fail = false;
purge.during = null;
localStorage.clear();
sessionStorage.clear();
await freshIndexedDb();
@@ -291,7 +303,7 @@ describe('what never wipes', () => {
it('a forgotten row: this device registers anew and keeps its data', async () => {
respond = (_url, init) => {
const { id } = JSON.parse(String(init?.body)) as { id?: string };
return id ? json(404, { error: 'unknown_device' }) : json(200, { id: BOB_ROW, secret: SECRET });
return id ? json(404, { error: 'unknown_device' }) : newRow(BOB_ROW);
};
await wipe.runDeviceGate();
expectUntouched();
@@ -336,7 +348,7 @@ describe('registering', () => {
});
it('a browser with offline on registers, once, naming its cookie slot', async () => {
respond = () => json(200, { id: ROW, secret: SECRET });
respond = () => newRow();
await Promise.all([wipe.checkInDue(true), wipe.checkInDue(true)]);
expect(sent).toHaveLength(1);
expect(sent[0].headers['X-JMAP-Cookie-Slot']).toBe('0');
@@ -353,7 +365,7 @@ describe('registering', () => {
it('a desktop app on an instance does, with offline off too', async () => {
state.writeEnabled([]);
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'instance' };
respond = () => json(200, { id: ROW, secret: SECRET });
respond = () => newRow();
await wipe.checkInDue(true);
expect(sent).toHaveLength(1);
});
@@ -365,10 +377,10 @@ describe('registering', () => {
});
it('a cleared key database means a new row', async () => {
respond = () => json(200, { id: ROW, secret: SECRET });
respond = () => newRow();
await wipe.checkInDue(true);
keys.deleteKeyDatabase();
respond = () => json(200, { id: BOB_ROW, secret: SECRET });
respond = () => newRow(BOB_ROW);
await wipe.checkInDue(true);
expect((await keys.getDeviceRecord(ALICE))?.id).toBe(BOB_ROW);
});
@@ -381,6 +393,36 @@ describe('registering', () => {
expect(await keys.listDeviceRecords()).toEqual([]);
});
it.each([
['someone else\'s row', { user: 'mallory', server: 'https://mail.example.test' }],
['another server\'s row', { user: 'alice', server: 'https://other.example.test' }],
])('drops %s: no record here, and the row goes back', async (_name, owner) => {
respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : json(200, { id: BOB_ROW, secret: SECRET, ...owner }));
await wipe.checkInDue(true);
expect(await keys.getDeviceRecord(ALICE)).toBeNull();
await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ id: BOB_ROW, secret: SECRET, drop: true }));
});
it.each([
['the same name in other case', { user: 'ALICE', server: 'https://mail.example.test/' }],
['the whole address for a bare name', { user: 'alice@mail.example.test', server: 'https://mail.example.test' }],
])('takes %s as the same owner', async (_name, owner) => {
respond = () => json(200, { id: ROW, secret: SECRET, ...owner });
await wipe.checkInDue(true);
expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW);
});
it('a row that was wiped already: the record goes, nothing else does, and it registers anew', async () => {
await keys.putDeviceRecord({ account: ALICE, id: ROW, secret: SECRET, createdAt: 1 });
respond = (_url, init) => (JSON.parse(String(init?.body)).id ? json(200, { wipe: null, wiped: true }) : newRow(BOB_ROW));
await wipe.checkInDue(true);
expect(await keys.getDeviceRecord(ALICE)).toBeNull();
expectUntouched();
await wipe.checkInDue(true);
expect((await keys.getDeviceRecord(ALICE))?.id).toBe(BOB_ROW);
expect(wipeState.consumeWipeNote()).toBeNull();
});
it('isn\'t tried again every minute when it can\'t', async () => {
respond = () => json(401, {});
const now = Date.now();
@@ -403,28 +445,200 @@ describe('check-ins while running', () => {
});
});
/** What the auth store does around the hook. */
function refuse(account = ALICE, certain = true): void {
wipeState.markLoginRefused(account, certain);
rejected.noteCredentialsRejected(account, certain);
useAccountStore.getState().removeAccount(account, 'evicted');
}
async function policyAsksForDeletion(): Promise<void> {
const { usePolicyStore } = await import('@/stores/policy-store');
const policy = { organisation: { devices: { wipeOnRefusedLogin: true } } };
usePolicyStore.setState({ policy, rawPolicy: policy, subject: null, loaded: true, source: 'server', partial: false, resolveFor: () => policy } as never);
}
const DAY = 24 * 60 * 60 * 1000;
describe('a refused login (lib/offline/credentials-rejected.ts)', () => {
it('takes everything beyond the store too, and tells the instance why', async () => {
it('hides the data and deletes nothing: everyone\'s keys, plugin settings and the store stay', async () => {
await openDb('smime-plugin-store');
respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe());
await openDb('bulwark-openpgp');
const { usePluginStore } = await import('@/stores/plugin-store');
usePluginStore.setState({ plugins: [{ id: 'managed', settings: { apiKey: 'k' } }] as never });
wipe.registerDeviceWipe();
rejected.noteCredentialsRejected(ALICE);
await vi.waitFor(() => expect(sent.some((call) => call.url.endsWith('/wiped'))).toBe(true));
expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'rejected', id: ROW });
await vi.waitFor(async () => expect(await databases()).not.toContain('smime-plugin-store'));
// The auth store is on its way with its own sign-out.
expect(useAccountStore.getState().accounts.map((account) => account.id)).toEqual([ALICE]);
refuse();
await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(0));
expect(state.isEvicted(ALICE)).toBe(true);
expect(state.visibleAccounts()).toEqual([]);
expect(purge.files).toBe(0);
expect(store.removed).toEqual([]);
expect(await databases()).toEqual(expect.arrayContaining(['smime-plugin-store', 'bulwark-openpgp']));
expect(usePluginStore.getState().plugins[0].settings).toEqual({ apiKey: 'k' });
expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]);
expect(wipeState.readPendingWipes()).toEqual({});
});
it('names the lost mail, as for any sign-out nobody asked for, and leaves no count note', async () => {
it('two weeks later a sign-in with the new password finds everything, unsent mail included', async () => {
await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] });
const lost = await import('../lost');
wipe.registerDeviceWipe();
rejected.noteCredentialsRejected(ALICE);
await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0));
await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({}));
expect(lost.consumeLost()).toEqual(['Unsent']);
expect(wipeState.consumeWipeNote()).toBeNull();
const at = Date.now();
refuse();
await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(1));
const offline = await import('../offline-mode');
const later = at + 14 * DAY;
await offline.sweepAccountsGone(later);
await wipe.sweepRefusedLogins();
expect(purge.files).toBe(0);
expect(store.removed).toEqual([]);
// What the sign-in page says, in numbers only.
const [wait] = wipeState.refusedWaits();
expect(wait).toEqual({ unsent: 1, until: expect.any(Number) });
expect(wait.until).toBeGreaterThanOrEqual(at + state.EVICTED_KEEP_MS);
expect(wait.until).toBeLessThanOrEqual(at + state.EVICTED_KEEP_MS + 60_000);
expect(JSON.stringify({ ...localStorage })).not.toContain('"Unsent"');
useAccountStore.getState().addAccount(entry(ALICE, 0) as never);
expect(state.isEvicted(ALICE)).toBe(false);
expect(state.visibleAccounts()).toEqual([ALICE]);
expect((await queue.readQueue(ALICE)).outbox.map((row) => row.summary.subject)).toEqual(['Unsent']);
await wipe.sweepRefusedLogins();
expect(wipeState.readRefusedLogins()).toEqual({});
expect(wipeState.refusedWaits()).toEqual([]);
});
it('with no sign-in for 30 days it goes, and names nothing to whoever is here', async () => {
await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] });
wipe.registerDeviceWipe();
const at = Date.now();
refuse();
await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(1));
const offline = await import('../offline-mode');
await offline.sweepAccountsGone(at + 29 * DAY);
expect(purge.files).toBe(0);
await offline.sweepAccountsGone(at + 31 * DAY);
expect(purge.files).toBeGreaterThan(0);
await wipe.sweepRefusedLogins();
expect(wipeState.refusedWaits()).toEqual([]);
const lost = await import('../lost');
expect(lost.consumeLost()).toEqual([]);
});
it('a count it can\'t get is unknown, not none, and is tried again at the next look', async () => {
await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] });
store.failArea = () => true;
wipe.registerDeviceWipe();
refuse();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(wipeState.refusedWaits()[0].unsent).toBeNull();
store.failArea = null;
await wipe.sweepRefusedLogins();
expect(wipeState.refusedWaits()[0].unsent).toBe(1);
});
it('keeps no wait for an account with nothing kept here', async () => {
state.writeEnabled([]);
wipe.registerDeviceWipe();
useAccountStore.getState().removeAccount(ALICE, 'evicted');
rejected.noteCredentialsRejected(ALICE, true);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(wipeState.refusedWaits()).toEqual([]);
expect(purge.files).toBe(0);
});
describe('with the organisation asking for deletion at once', () => {
beforeEach(async () => {
await policyAsksForDeletion();
respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe());
});
it('takes that account\'s data and nothing shared, and tells the instance why', async () => {
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never);
state.writeEnabled([ALICE, BOB]);
await keys.openAccountKey(BOB);
await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 });
await openDb('smime-plugin-store');
await openDb('bulwark-openpgp');
const { fileStorage } = await import('@/lib/plugin-storage');
const clearFiles = vi.spyOn(fileStorage, 'clearFiles').mockResolvedValue();
const handoffs = await import('@/lib/launch-handoff');
const clearHandoffs = vi.spyOn(handoffs, 'clearHandoffs');
const { pluginStoragePrefix } = await import('@/lib/plugin-sandbox/storage-scope');
localStorage.setItem(`${pluginStoragePrefix('notes', ALICE)}note`, 'hers');
localStorage.setItem(`${pluginStoragePrefix('notes', BOB)}note`, 'his');
localStorage.setItem('notification-center', '[{"title":"From Eve"}]');
localStorage.setItem('template-storage', '{"templates":[]}');
const { usePluginStore } = await import('@/stores/plugin-store');
usePluginStore.setState({ plugins: [{ id: 'managed', settings: { apiKey: 'k' } }] as never });
wipe.registerDeviceWipe();
refuse();
await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'rejected', id: ROW }));
await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({}));
expect(store.removed).toEqual([ALICE]);
expect(await databases()).toEqual(expect.arrayContaining(['smime-plugin-store', 'bulwark-openpgp']));
expect(usePluginStore.getState().plugins[0].settings).toEqual({ apiKey: 'k' });
expect(localStorage.getItem(`${pluginStoragePrefix('notes', ALICE)}note`)).toBeNull();
expect(localStorage.getItem(`${pluginStoragePrefix('notes', BOB)}note`)).toBe('his');
expect(localStorage.getItem('notification-center')).not.toBeNull();
expect(localStorage.getItem('template-storage')).not.toBeNull();
expect(clearFiles).not.toHaveBeenCalled();
expect(clearHandoffs).not.toHaveBeenCalled();
expect((await keys.getDeviceRecord(BOB))?.id).toBe(BOB_ROW);
expect(state.useOfflineStore.getState().enabled).toEqual([BOB]);
});
it('with offline off touches nothing but what\'s the account\'s own', async () => {
state.writeEnabled([]);
await openDb('smime-plugin-store');
const handoffs = await import('@/lib/launch-handoff');
const clearHandoffs = vi.spyOn(handoffs, 'clearHandoffs');
const { pluginStoragePrefix } = await import('@/lib/plugin-sandbox/storage-scope');
localStorage.setItem(`${pluginStoragePrefix('notes', ALICE)}note`, 'hers');
localStorage.setItem('notification-center', '[{"title":"From Eve"}]');
wipe.registerDeviceWipe();
rejected.noteCredentialsRejected(ALICE, true);
await vi.waitFor(() => expect(localStorage.getItem(`${pluginStoragePrefix('notes', ALICE)}note`)).toBeNull());
await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({}));
expect(purge.files).toBe(0);
expect(store.removed).toEqual([]);
expect(await databases()).toContain('smime-plugin-store');
expect(localStorage.getItem('notification-center')).not.toBeNull();
expect(clearHandoffs).not.toHaveBeenCalled();
});
it('records a real count for the audit, however it\'s named here', async () => {
await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] });
wipe.registerDeviceWipe();
refuse();
await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'rejected', unsent: 1 }));
});
it('doesn\'t delete on a refusal that hasn\'t lasted: one bad answer at a start only hides', async () => {
wipe.registerDeviceWipe();
refuse(ALICE, false);
await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(0));
expect(purge.files).toBe(0);
expect(store.removed).toEqual([]);
});
it('reaches a refusal from before the policy said so', async () => {
const { usePolicyStore } = await import('@/stores/policy-store');
const none = { organisation: { devices: {} } };
usePolicyStore.setState({ policy: none, rawPolicy: none, resolveFor: () => none } as never);
const stop = wipe.startDeviceWipe();
refuse();
await vi.waitFor(() => expect(wipeState.readRefusedLogins()[ALICE]?.unsent).toBe(0));
expect(purge.files).toBe(0);
await policyAsksForDeletion();
await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0));
stop();
});
});
describe('from a running renewal', () => {
@@ -454,59 +668,42 @@ describe('a refused login (lib/offline/credentials-rejected.ts)', () => {
expect(wipeState.readPendingWipes()).toEqual({});
});
it.each([
['invalid_grant', 'invalid_grant'],
['the session limit', 'session_expired'],
])('%s fires it and wipes', async (_name, error) => {
it('invalid_grant fires it, signs out and hides: nothing is deleted', async () => {
wipe.registerDeviceWipe();
token(() => json(401, { error, maxHours: 8 }));
token(() => json(401, { error: 'invalid_grant' }));
await useAuthStore.getState().refreshAccessToken({ accountId: ALICE });
expect(heard).toEqual([ALICE]);
await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0));
await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined());
expect(state.isEvicted(ALICE)).toBe(true);
expect(purge.files).toBe(0);
expect(store.removed).toEqual([]);
expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]);
});
});
it('a password refused for a day goes, unless it signed in again', async () => {
const heard: string[] = [];
rejected.onCredentialsRejected((account) => heard.push(account));
state.markEvicted(ALICE);
state.writeEnabled([ALICE]);
const at = Date.now();
wipeState.markLoginRefused(ALICE, at);
await wipe.sweepRefusedLogins(at + wipeState.REFUSED_KEEP_MS - 1000);
expect(heard).toEqual([]);
expect(purge.files).toBe(0);
state.clearEvicted(ALICE);
await wipe.sweepRefusedLogins(at + wipeState.REFUSED_KEEP_MS + 1000);
expect(heard).toEqual([]);
expect(purge.files).toBe(0);
expect(wipeState.readRefusedLogins()).toEqual({});
state.markEvicted(ALICE);
wipeState.markLoginRefused(ALICE, at);
wipe.registerDeviceWipe();
await wipe.sweepRefusedLogins(at + wipeState.REFUSED_KEEP_MS + 1000);
expect(heard).toEqual([ALICE]);
expect(purge.files).toBeGreaterThan(0);
expect(state.useOfflineStore.getState().enabled).toEqual([]);
expect(wipeState.readRefusedLogins()).toEqual({});
it('the session limit stays out of it: no hook, and the data waits', async () => {
wipe.registerDeviceWipe();
token(() => json(401, { error: 'session_expired', maxHours: 8 }));
await useAuthStore.getState().refreshAccessToken({ accountId: ALICE });
expect(heard).toEqual([]);
await vi.waitFor(() => expect(state.isEvicted(ALICE)).toBe(true));
expect(purge.files).toBe(0);
expect(store.removed).toEqual([]);
expect(wipeState.readRefusedLogins()).toEqual({});
});
});
});
describe('other windows', () => {
it('a window showing the wiped account reloads, one showing another stays, and the wiping window keeps going', async () => {
it('a window showing the wiped account reloads at the end, and only then', async () => {
const reload = vi.fn();
vi.stubGlobal('location', { ...window.location, reload });
const stop = wipe.startDeviceWipe();
const other = new BroadcastChannel('bulwark-device-wipe');
other.postMessage({ account: BOB });
other.postMessage({ account: ALICE, phase: 'start' });
await new Promise((resolve) => setTimeout(resolve, 30));
expect(reload).not.toHaveBeenCalled();
other.postMessage({ account: ALICE });
other.postMessage({ account: ALICE, phase: 'end' });
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1));
other.close();
@@ -515,6 +712,44 @@ describe('other windows', () => {
vi.stubGlobal('fetch', vi.fn(async () => noWipe()));
});
it.each(['start', 'end'])('a window showing another account lets go of the wiped one at the %s, in place', async (phase) => {
const reload = vi.fn();
vi.stubGlobal('location', { ...window.location, reload });
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never);
state.writeEnabled([ALICE, BOB]);
const stop = wipe.startDeviceWipe();
const other = new BroadcastChannel('bulwark-device-wipe');
other.postMessage({ account: BOB, phase });
await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(BOB)).toBeUndefined());
// Its own registry won't write it back, and the wiping window's work isn't done over here.
expect(reload).not.toHaveBeenCalled();
expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]);
expect(state.isEvicted(BOB)).toBe(false);
expect(store.removed).toEqual([]);
expect(purge.files).toBe(0);
expect(JSON.parse(localStorage.getItem('account-registry') ?? '{"state":{"accounts":[]}}').state.accounts.map((a: { id: string }) => a.id)).not.toContain(BOB);
other.close();
stop();
vi.unstubAllGlobals();
vi.stubGlobal('fetch', vi.fn(async () => noWipe()));
});
it('a wipe tells the others when it starts and when it ends', async () => {
const heard: unknown[] = [];
const other = new BroadcastChannel('bulwark-device-wipe');
other.onmessage = (event) => heard.push(event.data);
respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe());
purge.during = () => { heard.push('deleting'); };
await wipe.wipeAccountFromDevice(ALICE, 'admin');
await vi.waitFor(() => expect(heard.at(-1)).toEqual({ account: ALICE, phase: 'end' }));
await new Promise((resolve) => setTimeout(resolve, 30));
expect(heard).toContainEqual({ account: ALICE, phase: 'start' });
other.close();
});
it('the window that wipes doesn\'t hear its own message', async () => {
const reload = vi.fn();
vi.stubGlobal('location', { ...window.location, reload });
@@ -591,3 +826,295 @@ describe('the wipe from settings', () => {
expect(sessionStorage.getItem('signed_out')).toBe('true');
});
});
describe('the report and the deletion', () => {
beforeEach(() => {
respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : wipeNow());
});
it('is queued before anything is deleted, and not sent until it\'s done', async () => {
let queuedWhenDeleting: string[] = [];
let sentWhenDeleting = -1;
purge.during = () => {
// The first time is the wipe's. The sign-out purges too, when all is done.
if (sentWhenDeleting >= 0) return;
queuedWhenDeleting = wipeState.readWipeConfirmations().map((entry) => entry.id);
sentWhenDeleting = sent.filter((call) => call.url.endsWith('/wiped')).length;
};
await wipe.wipeAccountFromDevice(ALICE, 'admin');
expect(queuedWhenDeleting).toEqual([ROW]);
expect(sentWhenDeleting).toBe(0);
expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(1);
expect(wipeState.readWipeConfirmations()).toEqual([]);
});
it('another contact during the wipe doesn\'t send it early', async () => {
let early = -1;
respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe());
registry.onDeviceWipe(async () => {
await wipe.checkInDue(true);
early = sent.filter((call) => call.url.endsWith('/wiped')).length;
});
await wipe.wipeAccountFromDevice(ALICE, 'admin');
expect(early).toBe(0);
expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(1);
});
it('a deletion that fails isn\'t reported as done: it stays pending and is finished at the next start', async () => {
purge.fail = true;
await wipe.wipeAccountFromDevice(ALICE, 'admin');
expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]);
expect(wipeState.readPendingWipes()).toEqual({ [ALICE]: 'admin' });
expect(wipeState.readWipeConfirmations().map((entry) => entry.id)).toEqual([ROW]);
// Signed out all the same: nothing more is shown.
expect(useAccountStore.getState().accounts).toEqual([]);
purge.fail = false;
await wipe.finishInterruptedWipes();
expect(purge.files).toBeGreaterThan(0);
expect(wipeState.readPendingWipes()).toEqual({});
expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ id: ROW, reason: 'admin' });
});
it('a plugin database a frame holds open isn\'t reported as wiped until it\'s gone', async () => {
const held = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open('smime-plugin-store', 1);
request.onupgradeneeded = () => { request.result.createObjectStore('s'); };
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
await wipe.wipeAccountFromDevice(ALICE, 'admin');
expect(Object.keys(wipeState.readBlockedDatabases())).toEqual(['smime-plugin-store']);
expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]);
expect(wipeState.readWipeConfirmations().map((entry) => entry.id)).toEqual([ROW]);
held.close();
await wipe.finishInterruptedWipes();
await wipe.checkInDue(true);
await vi.waitFor(() => expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(1));
expect(wipeState.readBlockedDatabases()).toEqual({});
expect(await databases()).not.toContain('smime-plugin-store');
});
});
describe('two wipes at once', () => {
beforeEach(async () => {
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never);
state.writeEnabled([ALICE, BOB]);
await keys.openAccountKey(BOB);
await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 });
respond = () => json(200, { ok: true });
});
it('neither puts back the row the other just removed', async () => {
await Promise.all([wipe.wipeAccountFromDevice(ALICE, 'admin'), wipe.wipeAccountFromDevice(BOB, 'admin')]);
expect(await keys.listDeviceRecords()).toEqual([]);
expect(useAccountStore.getState().accounts).toEqual([]);
expect(wipeState.readPendingWipes()).toEqual({});
expect(sent.filter((call) => call.url.endsWith('/wiped')).map((call) => call.body.id).sort()).toEqual([ROW, BOB_ROW].sort());
});
it('one runs after the other: they don\'t overlap', async () => {
const running: string[] = [];
let overlapped = false;
registry.onDeviceWipe(async (account) => {
if (running.length > 0) overlapped = true;
running.push(String(account));
await new Promise((resolve) => setTimeout(resolve, 20));
running.pop();
});
await Promise.all([wipe.wipeAccountFromDevice(ALICE, 'admin'), wipe.wipeAccountFromDevice(BOB, 'admin')]);
expect(overlapped).toBe(false);
});
it('a wipe heard twice is done once: the second finds the row gone', async () => {
await wipe.wipeAccountFromDevice(ALICE, 'admin', { ifRow: ROW });
const reports = sent.filter((call) => call.url.endsWith('/wiped')).length;
expect(wipeState.consumeWipeNote()).toEqual({ unsent: 0 });
await wipe.wipeAccountFromDevice(ALICE, 'admin', { ifRow: ROW });
expect(sent.filter((call) => call.url.endsWith('/wiped'))).toHaveLength(reports);
expect(wipeState.consumeWipeNote()).toBeNull();
expect(wipeState.readPendingWipes()).toEqual({});
});
it('takes the Web Lock where the browser has one', async () => {
const names: string[] = [];
vi.stubGlobal('navigator', Object.assign(Object.create(navigator), {
locks: { request: async (name: string, work: () => Promise<unknown>) => { names.push(name); return work(); } },
}));
await wipe.wipeAccountFromDevice(ALICE, 'admin');
vi.stubGlobal('navigator', Object.getPrototypeOf(navigator));
expect(names).toContain('bulwark-device-wipe');
});
});
describe('a sign-out', () => {
beforeEach(() => {
respond = () => json(200, { ok: true });
});
it('drops the row with its secret', async () => {
wipe.registerDeviceWipe();
await useAuthStore.getState().logout({ endProviderSession: false });
await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toEqual({ id: ROW, secret: SECRET, drop: true }));
expect(wipeState.readWipeConfirmations()).toEqual([]);
});
it('removing another account drops its row too, and keeps this one\'s', async () => {
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never);
await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 });
wipe.registerDeviceWipe();
useAuthStore.getState().removeAccount(BOB);
await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toEqual({ id: BOB_ROW, secret: SECRET, drop: true }));
expect(await keys.getDeviceRecord(BOB)).toBeNull();
expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW);
});
it('a sign-out that keeps the data keeps the row: an admin can still reach it', async () => {
wipe.registerDeviceWipe();
await useAuthStore.getState().logout({ endProviderSession: false, keepLocalData: true });
await new Promise((resolve) => setTimeout(resolve, 30));
expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]);
expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW);
});
it('gets through at the next contact when the instance was out of reach', async () => {
respond = () => json(503, {});
wipe.registerDeviceWipe();
await useAuthStore.getState().logout({ endProviderSession: false });
await vi.waitFor(() => expect(wipeState.readWipeConfirmations().map((entry) => entry.id)).toEqual([ROW]));
respond = () => json(200, { ok: true });
await wipe.checkInDue(true);
await vi.waitFor(() => expect(wipeState.readWipeConfirmations()).toEqual([]));
});
});
describe('counting what a wipe loses', () => {
const offline = () => import('../offline-mode');
it('per account: each row reports its own, not the device\'s total', async () => {
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never);
state.writeEnabled([ALICE, BOB]);
await keys.openAccountKey(BOB);
await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 });
await queue.queueSend(ALICE, { subject: 'One', to: [] }, { kind: 'email', args: [] });
await queue.queueSend(ALICE, { subject: 'Two', to: [] }, { kind: 'email', args: [] });
respond = () => json(200, { ok: true });
await wipe.wipeWholeDevice('user');
const reports = sent.filter((call) => call.url.endsWith('/wiped')).map((call) => call.body);
expect(reports.find((body) => body.id === ROW)).toMatchObject({ unsent: 2 });
expect(reports.find((body) => body.id === BOB_ROW)).toMatchObject({ unsent: 0 });
});
it('a store that can\'t answer gives unknown, not nought', async () => {
store.failArea = () => true;
expect(await (await offline()).unsentForWipe(ALICE)).toBeNull();
});
it('a store that doesn\'t answer in time gives unknown, not nought', async () => {
const api = await import('@/lib/store/client/api');
vi.spyOn(api.getStore(), 'area').mockImplementation(() => new Promise(() => {}));
expect(await (await offline()).unsentForWipe(ALICE, 20)).toBeNull();
vi.restoreAllMocks();
});
it('waits longer for it than a sign-out does', async () => {
expect((await offline()).UNSENT_FOR_WIPE_MS).toBeGreaterThanOrEqual(10_000);
});
it('reports an unknown count as such, and says so at the next sign-in', async () => {
store.failArea = () => true;
respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe());
await wipe.wipeAccountFromDevice(ALICE, 'admin');
expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ unsent: null });
expect(wipeState.consumeWipeNote()).toEqual({ unsent: 0, unknown: true });
});
it('counts an account nobody can see too: it goes with the device', async () => {
await queue.queueSend(ALICE, { subject: 'Hidden', to: [] }, { kind: 'email', args: [] });
state.markEvicted(ALICE);
expect(await (await offline()).unsentForWipe('all')).toBe(1);
});
});
describe('what else goes with a wipe', () => {
const ACCOUNT_KEYS = [
'pro-tabs', 'search-history-storage', 'files-favorites', 'files-recent-files', 'files-last-parent-id', 'files-path-stack',
'bulwark-delegate-names', 'calendar-notification-storage', 'identity-storage', 'contact-storage', 'calendar-storage',
'webdav-favorites', 'webdav-recent-files', 'webdav-last-path', 'template-storage',
];
let closed: string[];
let unsubscribed: number;
let deletedCaches: string[];
function plant(): void {
for (const key of ACCOUNT_KEYS) localStorage.setItem(key, 'x');
localStorage.setItem('settings-storage', 'device preference');
localStorage.setItem('theme-storage', 'device preference');
localStorage.setItem('email-snapshot', JSON.stringify({ account: ALICE, emails: [{ subject: 'From Eve' }] }));
sessionStorage.setItem('bulwark-rescued-drafts', JSON.stringify([{ subject: 'Half written', body: 'text' }]));
}
beforeEach(() => {
respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe());
closed = [];
unsubscribed = 0;
deletedCaches = [];
plant();
const registration = {
getNotifications: async () => [{ title: 'From Eve', close() { closed.push(this.title); } }],
pushManager: { getSubscription: async () => ({ unsubscribe: async () => { unsubscribed += 1; return true; } }) },
};
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { getRegistration: async () => registration, controller: null } });
vi.stubGlobal('caches', {
keys: async () => ['bulwark-push-state-v1', 'unrelated'],
delete: async (name: string) => { deletedCaches.push(name); return true; },
open: async () => ({ put: async () => {} }),
});
});
afterEach(() => {
delete (navigator as { serviceWorker?: unknown }).serviceWorker;
vi.unstubAllGlobals();
});
it('an admin wipe takes them, closes what\'s on screen, and the last account ends Web Push', async () => {
const { saveForwardApprovals, forwardApprovals } = await import('@/lib/plugin-sandbox/forward-approvals');
saveForwardApprovals('forwarder', ALICE, ['eve@example.org']);
await wipe.wipeAccountFromDevice(ALICE, 'admin');
for (const key of ACCOUNT_KEYS) expect([key, localStorage.getItem(key)]).toEqual([key, null]);
expect(localStorage.getItem('email-snapshot')).toBeNull();
expect(sessionStorage.getItem('bulwark-rescued-drafts')).toBeNull();
expect(forwardApprovals('forwarder', ALICE)).toEqual([]);
expect(closed).toEqual(['From Eve']);
expect(deletedCaches).toContain('bulwark-push-state-v1');
expect(unsubscribed).toBe(1);
expect(localStorage.getItem('settings-storage')).toBe('device preference');
expect(localStorage.getItem('theme-storage')).toBe('device preference');
});
it('with another account left, Web Push stays: it\'s shared', async () => {
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never);
await wipe.wipeAccountFromDevice(ALICE, 'admin');
expect(closed).toEqual(['From Eve']);
expect(unsubscribed).toBe(0);
});
it('a wipe from settings takes them too', async () => {
await wipe.wipeWholeDevice('user');
for (const key of ACCOUNT_KEYS) expect([key, localStorage.getItem(key)]).toEqual([key, null]);
expect(unsubscribed).toBe(1);
});
it('a deletion after a refused login leaves all of it: only the account\'s own snapshot goes', async () => {
await policyAsksForDeletion();
wipe.registerDeviceWipe();
refuse();
await vi.waitFor(() => expect(store.removed.length + purge.files).toBeGreaterThan(0));
await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({}));
expect(localStorage.getItem('email-snapshot')).toBeNull();
for (const key of ACCOUNT_KEYS) expect([key, localStorage.getItem(key)]).toEqual([key, 'x']);
expect(closed).toEqual([]);
expect(unsubscribed).toBe(0);
});
});
@@ -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. lib/offline/device-wipe.ts
* registers here for everything beyond the local store. 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.
}
+324 -88
View File
@@ -4,9 +4,16 @@
*
* - 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 server refusing the stored login for good, through
* lib/offline/credentials-rejected.ts.
* - 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.
*
@@ -24,6 +31,10 @@
* 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';
@@ -32,13 +43,19 @@ import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugi
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 {
@@ -52,7 +69,7 @@ import {
readPendingWipes,
readRefusedLogins,
readWipeConfirmations,
REFUSED_KEEP_MS,
setRefusedCount,
WHOLE_DEVICE,
writeBlockedDatabases,
} from '@/lib/devices/wipe-state';
@@ -63,11 +80,13 @@ import {
putDeviceRecord,
type DeviceRecord,
} from '@/lib/store/client/keys';
import { useAccountStore } from '@/stores/account-store';
import { onAccountRemoved, useAccountStore, type AccountEntry } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { noteCredentialsRejected, onCredentialsRejected } from './credentials-rejected';
import { confirmLoss, registerOfflineHooks, unsentCount, wipeAccount, wipeEverything } from './offline-mode';
import { clearEvicted, isEvicted, readEvicted, useOfflineStore } from './state';
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. */
@@ -80,9 +99,9 @@ const TICK_MS = 60 * 1000;
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 DATABASE_RETRIES = 5;
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([
@@ -95,11 +114,37 @@ const APP_DATABASES = new Set([
/** 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. */
const SHARED_LOCAL_KEYS = ['notification-center', 'plugin-oauth-states', 'webmail_usernames', 'global-search', 'favicon-refresh'];
/**
* 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> {
@@ -125,10 +170,13 @@ function deleteDatabase(name: string): Promise<boolean> {
* 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 names = new Set([...KNOWN_PLUGIN_DATABASES, ...Object.keys(readBlockedDatabases())]);
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);
@@ -136,12 +184,9 @@ async function deletePluginDatabases(): Promise<void> {
} catch {
// No list in this browser: the names above.
}
const tries = readBlockedDatabases();
const blocked: Record<string, number> = {};
await Promise.all([...names].map(async (name) => {
if (await deleteDatabase(name)) return;
const count = (tries[name] ?? 0) + 1;
if (count < DATABASE_RETRIES) blocked[name] = count;
if (!(await deleteDatabase(name))) blocked[name] = (tries[name] ?? 0) + 1;
}));
writeBlockedDatabases(blocked);
}
@@ -175,6 +220,8 @@ async function forgetPluginAccounts(account: string | null): Promise<void> {
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> {
@@ -182,25 +229,51 @@ async function forgetPluginSettings(): Promise<void> {
usePluginStore.setState((state) => ({ plugins: state.plugins.map((plugin) => ({ ...plugin, settings: {} })) }));
}
function removeLocalKeys(keys: string[]): void {
for (const key of keys) {
try {
localStorage.removeItem(key);
} catch {
/* storage unavailable */
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 */
}
}
/** Everything else that's the account's or that accounts share. `account` null: all of it. */
async function scrub(account: string | null): Promise<void> {
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 forgetPluginAccounts(account);
await forgetPluginSettings().catch(() => {});
if (account === null) clearAllPluginStorage();
else clearPluginStorageForAccount(account);
await fileStorage.clearFiles().catch(() => {});
await clearAllCheckpoints().catch(() => {});
await sweepPrivateExports({ force: true }).catch(() => {});
@@ -210,13 +283,30 @@ async function scrub(account: string | null): Promise<void> {
/* storage unavailable */
}
clearHandoffs();
clearRescuedDrafts();
const { useNotificationCenterStore } = await import('@/stores/notification-center-store');
useNotificationCenterStore.getState().clear();
removeLocalKeys(SHARED_LOCAL_KEYS);
await closeShownNotifications().catch(() => {});
if (typeof caches !== 'undefined') await caches.delete(PUSH_STATE_CACHE).catch(() => {});
removeLocalKeys(SHARED_LOCAL_KEYS, SHARED_LOCAL_PREFIXES);
}
async function flushConfirmations(): Promise<void> {
await Promise.all(readWipeConfirmations().map(async (entry) => {
/**
* 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);
}));
}
@@ -242,8 +332,10 @@ function otherWindows(): BroadcastChannel | null {
}
}
function tellOtherWindows(account: string | null): void {
otherWindows()?.postMessage({ account });
type WipePhase = 'start' | 'end';
function tellOtherWindows(account: string | null, phase: WipePhase): void {
otherWindows()?.postMessage({ account, phase });
}
async function signOutAccount(account: string): Promise<void> {
@@ -253,72 +345,137 @@ async function signOutAccount(account: string): Promise<void> {
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 and signs it out. A refused login's sign-out
* is the auth store's own, already on its way, and its lost mail is named as for
* any sign-out nobody asked for. Everywhere else lost mail is a count.
* 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): Promise<void> {
export function wipeAccountFromDevice(account: string, reason: WipeReason, options: WipeAccountOptions = {}): Promise<void> {
const under = running.get(account);
if (under) return under;
const work = eraseAccount(account, reason).finally(() => running.delete(account));
// 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): Promise<void> {
markWipePending(account, reason);
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);
const named = reason === 'rejected';
const unsent = named ? 0 : (await unsentCount(account).catch(() => ({ messages: 0 }))).messages;
if (options.ifRow && record?.id !== options.ifRow) {
clearWipePending(account);
return;
}
tellOtherWindows(account, 'start');
registerOfflineHooks();
if (!named) confirmLoss(account);
await wipeAccount(account).catch(() => {});
clearEvicted(account);
dropRefusedLogin(account);
await scrub(account).catch(() => {});
// The last account's wipe deletes the whole key database, with the rows of accounts that stay.
await deleteDeviceRecord(account).catch(() => {});
for (const other of records) {
if (other.account !== account) await putDeviceRecord(other).catch(() => {});
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);
}
if (record) queueWipeConfirmation({ id: record.id, secret: record.secret, reason, unsent });
if (reason === 'admin') addWipeNote(unsent);
await flushConfirmations();
if (!named) await signOutAccount(account);
clearWipePending(account);
tellOtherWindows(account);
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;
const work = eraseDevice(reason).finally(() => running.delete(WHOLE_DEVICE));
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> {
markWipePending(WHOLE_DEVICE, reason);
const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]);
const unsent = (await unsentCount('all').catch(() => ({ messages: 0 }))).messages;
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 scrub(null).catch(() => {});
await wipeEverything().catch(() => {});
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);
for (const record of records) queueWipeConfirmation({ id: record.id, secret: record.secret, reason, unsent });
if (reason === 'admin') addWipeNote(unsent);
await flushConfirmations();
await flushConfirmations(deleted ? WHOLE_DEVICE : undefined);
signalSignedOut();
await useAuthStore.getState().logoutAll({ endProviderSession: false });
clearWipePending(WHOLE_DEVICE);
tellOtherWindows(null);
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 ----
@@ -344,26 +501,51 @@ 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');
await wipeAccountFromDevice(record.account, 'admin', { ifRow: record.id });
return;
}
if (answer === 'forgotten') {
// 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 deleteDeviceRecord(record.account).catch(() => {});
if (!known && !useOfflineStore.getState().enabled.includes(record.account)) await dropRows(record.account);
}
}
async function registerAccount(accountId: string, slot: number): Promise<void> {
/**
* 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(accountId)) return;
const row = await registerWithInstance(slot, AbortSignal.timeout(REPORT_BUDGET_MS * 3));
if (row) await putDeviceRecord({ account: accountId, id: row.id, secret: row.secret, createdAt: Date.now() });
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();
@@ -393,7 +575,7 @@ async function runCheckIns(force: boolean, now: number): Promise<void> {
.filter((account) => force || now - (lastRegisterTry.get(account.id) ?? 0) >= REGISTER_RETRY_MS)
.map((account) => {
lastRegisterTry.set(account.id, now);
return registerAccount(account.id, account.cookieSlot ?? 0).catch(() => {});
return registerAccount(account).catch(() => {});
}),
]);
}
@@ -408,18 +590,46 @@ export async function finishInterruptedWipes(): Promise<void> {
await retryBlockedDatabases();
}
/** Refused passwords whose day has passed, with no sign-in since. */
export async function sweepRefusedLogins(now = Date.now()): Promise<void> {
for (const [account, at] of Object.entries(readRefusedLogins())) {
// Signed in again: the wait is over and nothing goes.
// ---- 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;
}
if (now - at < REFUSED_KEEP_MS) continue;
dropRefusedLogin(account);
noteCredentialsRejected(account);
await running.get(account);
// 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);
}
}
@@ -429,8 +639,14 @@ let registered = false;
export function registerDeviceWipe(): void {
if (registered) return;
registered = true;
onCredentialsRejected((account) => {
void wipeAccountFromDevice(account, 'rejected');
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);
});
}
@@ -450,13 +666,29 @@ export async function runDeviceGate(): Promise<void> {
]);
}
/**
* 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 }>) => {
const { account } = event.data ?? {};
// Its mail is in this window's memory. Another account's unsaved text isn't touched.
if (account === null || account === useAuthStore.getState().activeAccountId) window.location.reload();
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; };
}
@@ -482,9 +714,13 @@ export function startDeviceWipe(): () => void {
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';
@@ -91,6 +91,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 }
@@ -1025,6 +1026,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;
@@ -1047,7 +1073,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);
@@ -1348,7 +1377,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();
}
+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);
+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 {
+6 -1
View File
@@ -5778,7 +5778,12 @@
"confirm_plugins": "البيانات التي خزّنتها الإضافات هنا",
"confirm_files": "المرفقات المفتوحة للتحرير والملفات المنتظرة للإرفاق",
"confirm_accounts": "يتم تسجيل الخروج من كل الحسابات",
"confirm_button": "إزالة كل شيء"
"confirm_button": "إزالة كل شيء",
"note_unknown": "أزالت مؤسستك بيانات هذا الحساب من هذا الجهاز. ربما حُذفت أيضًا رسائل لم تُرسل.",
"refused_waiting": "رسائل لم تُرسل تنتظر كلمة مرورك الجديدة: {count}. تبقى حتى {date}.",
"refused_waiting_unknown": "ربما توجد رسائل لم تُرسل تنتظر كلمة مرورك الجديدة. تبقى حتى {date}.",
"confirm_unsent_unknown": "ربما توجد هنا رسائل لم تُرسل. ستُفقد.",
"confirm_templates": "قوالب البريد الإلكتروني المحفوظة على هذا الجهاز"
},
"offlineStorage": {
"keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة على هذا الجهاز. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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í.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"confirm_plugins": "داده‌هایی که افزونه‌ها اینجا ذخیره کرده‌اند",
"confirm_files": "پیوست‌های بازشده برای ویرایش و فایل‌های در انتظار پیوست",
"confirm_accounts": "از همهٔ حساب‌ها خارج می‌شوید",
"confirm_button": "حذف همه چیز"
"confirm_button": "حذف همه چیز",
"note_unknown": "سازمان شما داده‌های این حساب را از این دستگاه حذف کرد. شاید پیام‌های ارسال‌نشده هم حذف شده باشند.",
"refused_waiting": "پیام‌های ارسال‌نشده در انتظار گذرواژه جدید شما: {count}. تا {date} نگه داشته می‌شوند.",
"refused_waiting_unknown": "شاید پیام‌های ارسال‌نشده در انتظار گذرواژه جدید شما باشند. تا {date} نگه داشته می‌شوند.",
"confirm_unsent_unknown": "شاید پیام‌های ارسال‌نشده اینجا منتظر باشند. از بین خواهند رفت.",
"confirm_templates": "الگوهای ایمیل ذخیره‌شده در این دستگاه"
},
"offlineStorage": {
"keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این دستگاه نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5779,7 +5779,12 @@
"confirm_plugins": "נתונים שתוספים שמרו כאן",
"confirm_files": "קבצים מצורפים שנפתחו לעריכה וקבצים שממתינים לצירוף",
"confirm_accounts": "מתבצעת יציאה מכל החשבונות",
"confirm_button": "הסרת הכול"
"confirm_button": "הסרת הכול",
"note_unknown": "הארגון שלך הסיר את נתוני החשבון הזה מהמכשיר הזה. ייתכן שגם הודעות שלא נשלחו נמחקו.",
"refused_waiting": "הודעות שלא נשלחו ממתינות לסיסמה החדשה שלך: {count}. הן יישמרו עד {date}.",
"refused_waiting_unknown": "ייתכן שהודעות שלא נשלחו ממתינות לסיסמה החדשה שלך. הן יישמרו עד {date}.",
"confirm_unsent_unknown": "ייתכן שיש כאן הודעות שלא נשלחו. הן יאבדו.",
"confirm_templates": "תבניות דוא\"ל שנשמרו במכשיר הזה"
},
"offlineStorage": {
"keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים במכשיר הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"confirm_plugins": "プラグインがここに保存したデータ",
"confirm_files": "編集のために開いた添付ファイルと、添付待ちのファイル",
"confirm_accounts": "すべてのアカウントがサインアウトされます",
"confirm_button": "すべて削除"
"confirm_button": "すべて削除",
"note_unknown": "組織がこのアカウントのデータをこのデバイスから削除しました。未送信のメッセージも削除された可能性があります。",
"refused_waiting": "新しいパスワードを待っている未送信メッセージ: {count} 件。{date} まで保持されます。",
"refused_waiting_unknown": "未送信のメッセージが新しいパスワードを待っている可能性があります。{date} まで保持されます。",
"confirm_unsent_unknown": "ここに未送信のメッセージがある可能性があります。失われます。",
"confirm_templates": "このデバイスに保存されたメールテンプレート"
},
"offlineStorage": {
"keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのデバイスに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"confirm_plugins": "플러그인이 여기에 저장한 데이터",
"confirm_files": "편집하려고 연 첨부 파일과 첨부를 기다리는 파일",
"confirm_accounts": "모든 계정이 로그아웃됩니다",
"confirm_button": "모두 제거"
"confirm_button": "모두 제거",
"note_unknown": "조직에서 이 계정의 데이터를 이 기기에서 삭제했습니다. 보내지 않은 메시지도 삭제되었을 수 있습니다.",
"refused_waiting": "새 비밀번호를 기다리는 보내지 않은 메시지: {count}개. {date}까지 보관됩니다.",
"refused_waiting_unknown": "보내지 않은 메시지가 새 비밀번호를 기다리고 있을 수 있습니다. {date}까지 보관됩니다.",
"confirm_unsent_unknown": "여기에 보내지 않은 메시지가 있을 수 있습니다. 사라지게 됩니다.",
"confirm_templates": "이 기기에 저장된 이메일 템플릿"
},
"offlineStorage": {
"keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 기기에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"confirm_plugins": "Өргөтгөлүүдийн энд хадгалсан өгөгдөл",
"confirm_files": "Засахаар нээсэн хавсралтууд болон хавсаргахаар хүлээж буй файлууд",
"confirm_accounts": "Бүх бүртгэлээс гарна",
"confirm_button": "Бүгдийг устгах"
"confirm_button": "Бүгдийг устгах",
"note_unknown": "Таны байгууллага энэ бүртгэлийн өгөгдлийг энэ төхөөрөмжөөс устгасан. Илгээгээгүй зурвасууд мөн устсан байж магадгүй.",
"refused_waiting": "Шинэ нууц үгийг тань хүлээж буй илгээгээгүй зурвас: {count}. {date} хүртэл хадгалагдана.",
"refused_waiting_unknown": "Илгээгээгүй зурвасууд шинэ нууц үгийг тань хүлээж байж магадгүй. {date} хүртэл хадгалагдана.",
"confirm_unsent_unknown": "Энд илгээгээгүй зурвас хүлээж байж магадгүй. Тэдгээр нь алдагдана.",
"confirm_templates": "Энэ төхөөрөмжид хадгалсан имэйлийн загварууд"
},
"offlineStorage": {
"keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ төхөөрөмж дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"confirm_plugins": "Данные, сохранённые здесь плагинами",
"confirm_files": "Вложения, открытые для редактирования, и файлы, ожидающие прикрепления",
"confirm_accounts": "Выполняется выход из всех учётных записей",
"confirm_button": "Удалить всё"
"confirm_button": "Удалить всё",
"note_unknown": "Ваша организация удалила данные этой учётной записи с этого устройства. Возможно, удалены и неотправленные сообщения.",
"refused_waiting": "Неотправленные сообщения ждут нового пароля: {count}. Они хранятся до {date}.",
"refused_waiting_unknown": "Возможно, неотправленные сообщения ждут нового пароля. Они хранятся до {date}.",
"confirm_unsent_unknown": "Здесь могут быть неотправленные сообщения. Они будут потеряны.",
"confirm_templates": "Шаблоны писем, сохранённые на этом устройстве"
},
"offlineStorage": {
"keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде на этом устройстве. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -2123,7 +2123,12 @@
"confirm_plugins": "ข้อมูลที่ปลั๊กอินเก็บไว้ที่นี่",
"confirm_files": "ไฟล์แนบที่เปิดเพื่อแก้ไขและไฟล์ที่รอแนบ",
"confirm_accounts": "ทุกบัญชีจะออกจากระบบ",
"confirm_button": "นำทุกอย่างออก"
"confirm_button": "นำทุกอย่างออก",
"note_unknown": "องค์กรของคุณได้ลบข้อมูลของบัญชีนี้ออกจากอุปกรณ์นี้แล้ว ข้อความที่ยังไม่ได้ส่งอาจถูกลบด้วย",
"refused_waiting": "ข้อความที่ยังไม่ได้ส่งซึ่งรอรหัสผ่านใหม่ของคุณ: {count} เก็บไว้ถึง {date}",
"refused_waiting_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรอรหัสผ่านใหม่ของคุณ เก็บไว้ถึง {date}",
"confirm_unsent_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรออยู่ที่นี่ ข้อความเหล่านั้นจะสูญหาย",
"confirm_templates": "เทมเพลตอีเมลที่บันทึกไว้ในอุปกรณ์นี้"
},
"offlineStorage": {
"keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในอุปกรณ์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"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"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"confirm_plugins": "Дані, збережені тут плагінами",
"confirm_files": "Вкладення, відкриті для редагування, і файли, що очікують прикріплення",
"confirm_accounts": "Вихід з усіх облікових записів",
"confirm_button": "Видалити все"
"confirm_button": "Видалити все",
"note_unknown": "Ваша організація видалила дані цього облікового запису з цього пристрою. Можливо, видалено й ненадіслані повідомлення.",
"refused_waiting": "Ненадіслані повідомлення чекають на новий пароль: {count}. Вони зберігаються до {date}.",
"refused_waiting_unknown": "Можливо, ненадіслані повідомлення чекають на новий пароль. Вони зберігаються до {date}.",
"confirm_unsent_unknown": "Тут можуть бути ненадіслані повідомлення. Їх буде втрачено.",
"confirm_templates": "Шаблони листів, збережені на цьому пристрої"
},
"offlineStorage": {
"keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді на цьому пристрої. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.",
+6 -1
View File
@@ -5790,7 +5790,12 @@
"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ứ"
"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.",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"confirm_plugins": "外掛程式儲存在這裡的資料",
"confirm_files": "為編輯而開啟的附件和等待附加的檔案",
"confirm_accounts": "所有帳號都將登出",
"confirm_button": "移除所有內容"
"confirm_button": "移除所有內容",
"note_unknown": "您的組織已從此裝置移除此帳戶的資料。未傳送的郵件可能也已被刪除。",
"refused_waiting": "等待新密碼的未傳送郵件:{count} 封。保留至 {date}。",
"refused_waiting_unknown": "可能有未傳送的郵件正在等待新密碼。保留至 {date}。",
"confirm_unsent_unknown": "這裡可能有未傳送的郵件。它們將會遺失。",
"confirm_templates": "儲存在此裝置上的郵件範本"
},
"offlineStorage": {
"keep_description": "在此裝置上加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。",
+6 -1
View File
@@ -5778,7 +5778,12 @@
"confirm_plugins": "插件保存在这里的数据",
"confirm_files": "为编辑而打开的附件和等待添加的文件",
"confirm_accounts": "所有帐户都将退出登录",
"confirm_button": "移除所有内容"
"confirm_button": "移除所有内容",
"note_unknown": "您的组织已从此设备中删除此账户的数据。未发送的邮件可能也已被删除。",
"refused_waiting": "等待新密码的未发送邮件:{count} 封。保留至 {date}。",
"refused_waiting_unknown": "可能有未发送的邮件在等待新密码。保留至 {date}。",
"confirm_unsent_unknown": "这里可能有未发送的邮件。它们将会丢失。",
"confirm_templates": "保存在此设备上的邮件模板"
},
"offlineStorage": {
"keep_description": "在此设备上加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。",
+98 -17
View File
@@ -2,8 +2,8 @@ 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 login the server ended fires the hook on every path, and a password
// it keeps refusing signs out and starts a wait instead of wiping.
// 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>,
@@ -50,14 +50,16 @@ vi.mock('@/lib/offline/offline-mode', () => ({
}));
const heard = vi.hoisted(() => [] as string[]);
const certain = vi.hoisted(() => [] as boolean[]);
vi.mock('@/lib/offline/credentials-rejected', () => ({
noteCredentialsRejected: (account: string) => { heard.push(account); },
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;
@@ -78,7 +80,7 @@ const json = (status: number, body: unknown) => new Response(JSON.stringify(body
beforeEach(async () => {
vi.resetModules();
({ useOfflineStore } = await import('@/lib/offline/state'));
({ 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');
@@ -86,6 +88,7 @@ beforeEach(async () => {
sessionStorage.clear();
order.length = 0;
heard.length = 0;
certain.length = 0;
clients.made.length = 0;
clients.connect = async () => {};
gate.run = async () => {};
@@ -161,17 +164,31 @@ describe('a login the server ended, at a start', () => {
useAuthStore.setState({ activeAccountId: ALICE });
});
it('invalid_grant fires the hook', async () => {
it('invalid_grant fires the hook, for certain, and only hides the data', async () => {
useOfflineStore.setState({ enabled: [ALICE] });
answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null);
await useAuthStore.getState().checkAuth();
expect(heard).toEqual([ALICE]);
// With mail kept here the real connect goes on behind the local copy.
await vi.waitFor(() => expect(heard).toEqual([ALICE]));
expect(certain).toEqual([true]);
expect(isEvicted(ALICE)).toBe(true);
expect(useOfflineStore.getState().enabled).toEqual([ALICE]);
expect(Object.keys(readRefusedLogins())).toEqual([ALICE]);
});
it('the session limit fires the hook, for a password login too', async () => {
useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')] } as never);
answer = (url, method) => (url.includes('/api/auth/session') && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null);
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();
expect(heard).toEqual([ALICE]);
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([
@@ -200,11 +217,23 @@ describe('a login the server ended, at a switch', () => {
useAuthStore.setState({ activeAccountId: ALICE, isAuthenticated: true });
});
it('invalid_grant fires the hook and takes the account out for good', async () => {
it('invalid_grant fires the hook and hides the account, keeping its data', async () => {
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(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 () => {
@@ -221,16 +250,65 @@ describe('a password the server keeps refusing', () => {
useOfflineStore.setState({ enabled: [ALICE] });
});
it('at a start: the wait begins, and no wipe', async () => {
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) => {
if ((client as { username?: string }).username === 'bob') (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(Object.keys(readRefusedLogins())).toEqual([ALICE]);
expect(heard).toEqual([]);
expect(useAccountStore.getState().accounts).toEqual([]);
expect(readRefusedLogins()).toEqual({});
expect(heard).toEqual([ALICE]);
});
it('at a start, with a server that only failed: no wait', async () => {
@@ -259,12 +337,15 @@ describe('a password the server keeps refusing', () => {
expect(readRefusedLogins()).toEqual({});
});
it('lasting a minute signs out and starts the wait, and fires no hook', async () => {
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([]);
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);
+70 -47
View File
@@ -39,7 +39,7 @@ import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/ch
import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role';
import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected';
import { CREDENTIALS_REFUSED_CONFIRM_MS, markLoginRefused, wipeWorkWaiting } from '@/lib/devices/wipe-state';
import { readEvicted, useOfflineStore } from '@/lib/offline/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';
@@ -796,23 +796,44 @@ export function pickEndSessionSlot(accounts: AccountEntry[], activeAccountId: st
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 kept refusing this login, after a fresh try each time. The account
* signs out and its data waits a day for a sign-in with the new password
* (lib/devices/wipe-state.ts, lib/offline/device-wipe.ts). It has no parsed
* refusal like `invalid_grant` to go by, so nothing goes at once.
* 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 {
markLoginRefused(accountId);
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 });
@@ -830,9 +851,11 @@ function bindClientStatusHandlers(
client.onCredentialsRefused?.((refusedForMs) => {
// A pop-out is on another window's session. Lite keeps no data to wait.
if (!accountId || IS_LITE || isPopoutWindow()) return;
refusedAtConnect.add(accountId);
// Still connecting: the restore that started this decides, when it fails.
if (clients.get(accountId) !== client) return;
if (clients.get(accountId) !== client) {
refusedAtConnect.add(accountId);
return;
}
if (refusedForMs >= CREDENTIALS_REFUSED_CONFIRM_MS) void endRefusedLogin(accountId, get);
});
@@ -1290,14 +1313,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;
}
@@ -1742,7 +1766,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({
@@ -1883,7 +1907,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({
@@ -1992,7 +2016,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);
@@ -2099,7 +2123,7 @@ export const useAuthStore = create<AuthState>()(
clearAllStores();
}
clients.set(accountId, client);
holdClient(accountId, client);
bindClientStatusHandlers(client, set, get, accountId);
accountStore.addAccount({
@@ -2248,7 +2272,7 @@ export const useAuthStore = create<AuthState>()(
clearAllStores();
}
clients.set(accountId, client);
holdClient(accountId, client);
bindClientStatusHandlers(client, set, get, accountId);
accountStore.addAccount({
@@ -2372,17 +2396,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
@@ -2712,7 +2737,7 @@ export const useAuthStore = create<AuthState>()(
let targetClient = clients.get(accountId);
const wasConnected = !!targetClient;
let targetRestoreRateLimited = false;
let targetLoginOver = false;
let targetRefused = false;
if (!targetClient) {
// Null out the client immediately so the page doesn't fire data-loading
@@ -2729,13 +2754,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) targetLoginOver = await loginReallyOver(res);
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,
@@ -2746,13 +2771,13 @@ export const useAuthStore = create<AuthState>()(
}
} else if (targetAccount.rememberMe) {
const res = await fetchSlotSession(targetAccount.cookieSlot);
if (!res.ok && res.status === 401) targetLoginOver = await loginReallyOver(res);
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);
}
}
@@ -2798,11 +2823,10 @@ export const useAuthStore = create<AuthState>()(
}
// Cannot restore - remove the stale account and redirect to login
if (targetLoginOver) noteCredentialsRejected(accountId);
else if (refusedAtConnect.has(accountId)) markLoginRefused(accountId);
if (targetRefused || refusedAtConnect.has(accountId)) noteRefusedLogin(accountId, targetRefused);
refusedAtConnect.delete(accountId);
evictAccount(accountId);
accountStore.removeAccount(accountId, targetLoginOver ? 'explicit' : 'evicted');
accountStore.removeAccount(accountId, 'evicted');
void clearSlotCredentials(targetAccount.cookieSlot, targetAccount.authMode === 'oauth');
// Restore the previous account if still available
@@ -3040,7 +3064,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 });
@@ -3051,7 +3075,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);
@@ -3061,7 +3085,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,
@@ -3072,7 +3096,7 @@ export const useAuthStore = create<AuthState>()(
} else if (res.status >= 500 || res.status === 429) {
throw new TransientAuthError('Session restore failed', res.status);
} else {
throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { loginOver: await loginReallyOver(res) });
throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { refused: await loginRefused(res) });
}
}
} catch (err) {
@@ -3095,7 +3119,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;
}
@@ -3109,13 +3133,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.
const loginOver = (err as { loginOver?: boolean }).loginOver === true;
if (loginOver) noteCredentialsRejected(account.id);
else if (refusedAtConnect.has(account.id)) markLoginRefused(account.id);
// 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, loginOver ? 'explicit' : 'evicted');
accountStore.removeAccount(account.id, 'evicted');
void clearSlotCredentials(account.cookieSlot, account.authMode === 'oauth');
}
};
@@ -3132,7 +3155,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);
@@ -3150,7 +3173,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(() => {});
@@ -3332,7 +3355,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
@@ -3400,7 +3423,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
@@ -3511,7 +3534,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,
@@ -3586,14 +3609,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;
}
};