Files
webmail/app/api/admin/devices/route.ts
T
Linus Rath 8523289a4b feat: let an admin wipe a lost device, and clear an account the server refuses for good
Until now a lost laptop kept its offline mail for as long as anyone could
open it, and an account whose login the server had ended kept its copy too.

- `lib/devices`, `app/api/devices`: browsers with offline on and desktop
  apps register, in a durable collection with no expiry. A check-in goes
  by a per-device secret, so a revoked device still hears its wipe.
- `app/api/admin/devices` and the org devices page: wipe one device or all
  of a user's, with audit entries for each request and each confirmation.
- `lib/offline/device-wipe.ts`: a start finishes any wipe cut short, then
  waits up to a second for the instance before showing the local copy. A
  wipe takes the store, keys, plugin databases and handoffs, signs out,
  and reports back, again at the next contact if the report failed.
- `lib/jmap/client.ts`, `stores/auth-store.ts`: only `invalid_grant`, the
  session limit, or a 401 lasting past a re-auth count as refused. A 500,
  a proxy page or an outage never do.
- Settings: "Remove everything from this device", with a confirmation.

`lib/shared-store/__tests__/import-data.test.ts` changes its assertion
because it lists every collection the import copies, and `devices` is new.
2026-11-03 13:19:44 +01:00

115 lines
4.7 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { auditLog } from '@/lib/admin/audit';
import { getClientIP, requireAdminAuth } from '@/lib/admin/session';
import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response';
import {
cancelDeviceWipe,
DEVICE_ID_RE,
forgetDevice,
getDevice,
isUserKey,
listDevices,
requestDeviceWipe,
requestUserWipe,
} from '@/lib/devices/registry';
import { AUDIT_DEVICE_FORGET, AUDIT_DEVICE_WIPE_CANCEL, AUDIT_DEVICE_WIPE_REQUEST, type DeviceView } from '@/lib/devices/types';
import { logger } from '@/lib/logger';
const MAX_PAGE = 200;
function positiveInt(raw: string | null, fallback: number): number {
const parsed = Number.parseInt(raw ?? '', 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}
function auditDetail(device: DeviceView, by: string): Record<string, unknown> {
return { device: device.id, user: device.user, server: device.server, platform: device.platform, by };
}
/** Nothing to do: the row as it stands, so a stale page catches up. Only a missing row is an error. */
async function unchanged(id: string): Promise<NextResponse> {
const device = await getDevice(id);
return device ? NextResponse.json({ device }) : NextResponse.json({ error: 'not found' }, { status: 404 });
}
/** GET /api/admin/devices?offset=&limit=&q= : the registered devices, a page at a time. */
export async function GET(request: NextRequest) {
try {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
const { searchParams } = request.nextUrl;
const page = await listDevices({
offset: positiveInt(searchParams.get('offset'), 0),
limit: Math.min(positiveInt(searchParams.get('limit'), 100) || 100, MAX_PAGE),
query: searchParams.get('q') ?? undefined,
});
return NextResponse.json(page, { headers: { 'Cache-Control': 'no-store' } });
} catch (error) {
const unavailable = storeUnavailableResponse(error);
if (unavailable) return unavailable;
logger.error('admin devices GET', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
/**
* POST /api/admin/devices
* { action: 'wipe' | 'cancel' | 'forget', id }
* { action: 'wipe-user', userKey }
*
* A wipe waits until the device next reaches this instance. Each request and
* each confirmed wipe goes in the audit log. Someone who controls the instance
* can wipe any device that checks in here: that's the admin's power, on the record.
*/
export async function POST(request: NextRequest) {
try {
const auth = await requireAdminAuth(request);
if ('error' in auth) return auth.error;
// The admin session has a role and no name.
const by = 'admin';
const ip = getClientIP(request);
const body = await request.json().catch(() => null) as { action?: unknown; id?: unknown; userKey?: unknown } | null;
const action = body?.action;
if (action === 'wipe-user') {
if (!isUserKey(body?.userKey)) return NextResponse.json({ error: 'invalid userKey' }, { status: 400 });
const devices = await requestUserWipe(body.userKey, by);
for (const device of devices) {
if (device.wipe) await auditLog(AUDIT_DEVICE_WIPE_REQUEST, auditDetail(device, by), ip);
}
return NextResponse.json({ devices });
}
if (typeof body?.id !== 'string' || !DEVICE_ID_RE.test(body.id)) {
return NextResponse.json({ error: 'invalid id' }, { status: 400 });
}
if (action === 'wipe') {
const device = await requestDeviceWipe(body.id, by);
if (!device) return unchanged(body.id);
await auditLog(AUDIT_DEVICE_WIPE_REQUEST, auditDetail(device, by), ip);
return NextResponse.json({ device });
}
if (action === 'cancel') {
const device = await cancelDeviceWipe(body.id);
if (!device) return unchanged(body.id);
await auditLog(AUDIT_DEVICE_WIPE_CANCEL, auditDetail(device, by), ip);
return NextResponse.json({ device });
}
if (action === 'forget') {
const device = await getDevice(body.id);
if (!device) return NextResponse.json({ error: 'not found' }, { status: 404 });
await forgetDevice(body.id);
await auditLog(AUDIT_DEVICE_FORGET, auditDetail(device, by), ip);
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
} catch (error) {
const unavailable = storeUnavailableResponse(error);
if (unavailable) return unavailable;
logger.error('admin devices POST', { error: error instanceof Error ? error.message : String(error) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}