mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user