Files
webmail/components/offline/wipe-device-section.tsx
T
Linus Rath 940a572844 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.
2026-11-03 15:11:47 +01:00

76 lines
2.8 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { wipeWholeDevice } from '@/lib/offline/device-wipe';
import { unsentForWipe } from '@/lib/offline/offline-mode';
/** The question doesn't wait as long for the count as the wipe itself does. */
const QUESTION_WAIT_MS = 2_000;
/**
* "Remove everything from this device", for someone giving a computer away or
* selling it. It signs out, so the question says exactly what goes.
*/
export function WipeDeviceSection() {
const t = useTranslations('offlineWipe');
const [asking, setAsking] = useState(false);
// Null: the store couldn't say, which isn't the same as none.
const [unsent, setUnsent] = useState<number | null>(0);
const [wiping, setWiping] = useState(false);
const ask = async () => {
setUnsent(await unsentForWipe('all', QUESTION_WAIT_MS).catch(() => null));
setAsking(true);
};
const confirmed = () => {
setAsking(false);
setWiping(true);
void wipeWholeDevice('user').finally(() => setWiping(false));
};
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border py-3" data-testid="wipe-device-section">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{t('settings_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('settings_description')}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="text-destructive"
disabled={wiping}
onClick={() => { void ask(); }}
data-testid="wipe-device-button"
>
{wiping ? t('settings_wiping') : t('settings_button')}
</Button>
<ConfirmDialog
isOpen={asking}
onClose={() => setAsking(false)}
onConfirm={confirmed}
title={t('confirm_title')}
message={t('confirm_message')}
details={(
<ul className="list-disc space-y-1 ps-5 text-sm text-muted-foreground" data-testid="wipe-device-details">
<li>{t('confirm_mail')}</li>
{unsent === null && <li className="font-medium text-foreground">{t('confirm_unsent_unknown')}</li>}
{unsent !== null && unsent > 0 && <li className="font-medium text-foreground">{t('confirm_unsent', { count: unsent })}</li>}
<li>{t('confirm_keys')}</li>
<li>{t('confirm_plugins')}</li>
<li>{t('confirm_files')}</li>
<li>{t('confirm_templates')}</li>
<li>{t('confirm_accounts')}</li>
</ul>
)}
confirmText={t('confirm_button')}
variant="destructive"
/>
</div>
);
}