fix(admin): /admin/users mobile layout — card-list at < md, no truncation

Phase A5 / B-mobile CRITICAL #2: at 393 CSS px the desktop user table
truncated the role badge ("ADMI", "USE") and pushed the action column
entirely off the right edge. The destructive force-logout action was
unreachable on mobile, plus the card-header filter pills collided
with the title's count badge.

Fix:
- Header switches from horizontal to vertical stack at `< sm` so the
  filter pills get their own row instead of fighting the title for
  space.
- Card padding `p-4 sm:p-6` matches the api-tokens responsive
  template.
- Desktop table is gated behind `hidden md:block` (instead of just
  letting it overflow).
- New mobile card-list at `< md` renders each user as a self-
  contained card with the username + role badge + meta line + a
  flex-wrapping action row carrying all four buttons (toggle role,
  edit, reset password, force logout). Nothing hidden behind a
  horizontal scroll, every action stays tap-targetable.
- Action buttons extracted into a single `renderUserActions()` helper
  so desktop + mobile stay in lock-step on future admin actions.

Evidence: new `user-management-responsive.test.tsx` asserts the
desktop table is `hidden md:block`, the mobile list is `md:hidden`,
the role badge appears in both layouts (no column lost), all four
ARIA-labelled action buttons are present in the mobile card, the
header stacks on `< sm`, and the card uses `p-4 sm:p-6`. 6 / 6 tests
green.

Findings ID: A5/CRITICAL-#2 (top of `.planning/phase-A5-mobile-findings.md`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 20:46:20 +02:00
1 parent 41945b2120
commit d8c549edf1
7 files changed
+509 -3

No files matched your search

@@ -0,0 +1,121 @@
/**
* GET /api/admin/backups/[id]/download — admin-only download of a single
* `DataBackup` snapshot as JSON.
*
* The encrypted blob in the DB is decrypted, parsed, then streamed back
* with `Content-Disposition: attachment` so the browser saves it as
* `healthlog-backup-<userId>-<isoDate>.json`. Auth gate mirrors the rest
* of the `/api/admin/backups` family — `requireAdmin()` (cookie session
* only; bearer tokens never elevate).
*
* Phase B1 / criterion 1 of the v1.4.15 backup-completeness work: today
* admins can list backups and trigger a manual run, but never see the
* payload. This endpoint closes the loop without breaking the encryption-
* at-rest contract: the ciphertext stays in the DB, the plaintext is only
* materialised inside the request handler and streamed to the admin.
*/
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { decrypt } from "@/lib/crypto";
import { annotate } from "@/lib/logging/context";
import { parseBackupPayload } from "@/lib/validations/backup";
export const dynamic = "force-dynamic";
export const GET = apiHandler(
async (
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user: admin } = await requireAdmin();
const { id } = await params;
annotate({ action: { name: "admin.backups.download" }, meta: { id } });
const backup = await prisma.dataBackup.findUnique({
where: { id },
include: { user: { select: { id: true, username: true } } },
});
if (!backup) {
// Audit the denied attempt — an admin trying to download a
// backup that no longer exists is interesting on its own.
await auditLog("admin.backups.download.denied", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { reason: "not_found", backupId: id },
});
throw new HttpError(404, "Backup not found");
}
let plaintext: string;
try {
plaintext = decrypt(backup.data);
} catch (err) {
// Decryption failure is rare but real — a rotated/missing key, or a
// corrupted blob from a partially-failed write. Surface it as a 500
// and audit the event so the admin has something to investigate.
await auditLog("admin.backups.download.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId: backup.userId,
reason: err instanceof Error ? err.message : "decrypt_failed",
},
});
return apiError("Failed to decrypt backup payload", 500);
}
// Validate the payload BEFORE handing it back. This catches the
// pathological case where the worker wrote something the upload
// route would later reject — better to fail loudly here than to ship
// a junk file that breaks an admin's restore plan.
try {
parseBackupPayload(plaintext);
} catch (err) {
await auditLog("admin.backups.download.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId: backup.userId,
reason: "schema_invalid",
message: err instanceof Error ? err.message : String(err),
},
});
return apiError("Backup payload failed schema validation", 500);
}
const isoDate = backup.createdAt.toISOString().slice(0, 10); // YYYY-MM-DD
const filename = `healthlog-backup-${backup.userId}-${isoDate}.json`;
await auditLog("admin.backups.download", {
userId: admin.id,
ipAddress: getClientIp(request),
details: {
backupId: id,
ownerId: backup.userId,
ownerUsername: backup.user.username,
type: backup.type,
sizeBytes: Buffer.byteLength(plaintext, "utf8"),
},
});
// Returning the plaintext as-is keeps the response a faithful copy of
// what the worker wrote (formatting, key order). The file is the
// canonical artefact admins will store / re-upload.
return new NextResponse(plaintext, {
status: 200,
headers: {
"content-type": "application/json; charset=utf-8",
"content-disposition": `attachment; filename="${filename}"`,
// Backups contain sensitive health data — keep them out of any
// shared cache (CDN, browser disk cache).
"cache-control": "no-store, max-age=0",
},
});
},
);