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

+6 -1
View File
@@ -1551,7 +1551,12 @@
"colType": "Typ",
"colSize": "Größe",
"colCreatedAt": "Letzter Snapshot",
"retentionLabel": "Aufbewahrung: {days} Tage"
"colActions": "Aktionen",
"retentionLabel": "Aufbewahrung: {days} Tage",
"download": "Herunterladen",
"downloadAria": "Backup für {username} herunterladen",
"downloadFailed": "Download fehlgeschlagen",
"downloadStarted": "Download gestartet"
},
"danger-zone": {
"title": "Gefahrenzone",
+6 -1
View File
@@ -1551,7 +1551,12 @@
"colType": "Type",
"colSize": "Size",
"colCreatedAt": "Last snapshot",
"retentionLabel": "Retention: {days} days"
"colActions": "Actions",
"retentionLabel": "Retention: {days} days",
"download": "Download",
"downloadAria": "Download backup for {username}",
"downloadFailed": "Download failed",
"downloadStarted": "Download started"
},
"danger-zone": {
"title": "Danger Zone",
@@ -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",
},
});
},
);
+63 -1
View File
@@ -12,7 +12,8 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, Loader2, PlayCircle } from "lucide-react";
import { Database, Download, Loader2, PlayCircle } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -71,6 +72,46 @@ export function BackupsSection() {
const rows: BackupRow[] = data?.rows ?? [];
// Per-row download in-flight state — keyed by backup id so two
// parallel clicks on different rows don't share a single spinner.
const [downloadingId, setDownloadingId] = useState<string | null>(null);
async function handleDownload(row: BackupRow) {
setDownloadingId(row.id);
try {
const res = await fetch(`/api/admin/backups/${row.id}/download`);
if (!res.ok) {
throw new Error(await getApiErrorMessage(res));
}
// Use the server-provided Content-Disposition filename if present,
// otherwise fall back to a deterministic client-side name. The
// server filename is more accurate (uses the actual createdAt).
const cd = res.headers.get("content-disposition") ?? "";
const match = cd.match(/filename="?([^";]+)"?/i);
const fallback = `healthlog-backup-${row.userId}-${row.createdAt.slice(0, 10)}.json`;
const filename = match?.[1] ?? fallback;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(t("admin.section.backups.downloadStarted"));
} catch (err) {
toast.error(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.downloadFailed"),
);
} finally {
setDownloadingId(null);
}
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center justify-between gap-2">
@@ -137,6 +178,9 @@ export function BackupsSection() {
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colCreatedAt")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.section.backups.colActions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
@@ -154,6 +198,24 @@ export function BackupsSection() {
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDateTime(row.createdAt)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
<Button
size="sm"
variant="outline"
disabled={downloadingId === row.id}
onClick={() => handleDownload(row)}
aria-label={t("admin.section.backups.downloadAria", {
username: row.username,
})}
>
{downloadingId === row.id ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1 h-3.5 w-3.5" />
)}
{t("admin.section.backups.download")}
</Button>
</td>
</tr>
))}
</tbody>
+4
View File
@@ -975,6 +975,10 @@ async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
]);
const backupJson = JSON.stringify({
// Bumped only when the on-disk shape changes incompatibly.
// Mirrors `BACKUP_SCHEMA_VERSION` in
// `src/lib/validations/backup.ts` — keep them in sync.
schemaVersion: "1",
exportedAt: new Date().toISOString(),
userId: user.id,
measurements: measurements.map((m) => ({
+153
View File
@@ -0,0 +1,153 @@
/**
* Zod schemas + helpers for the per-user `DataBackup` JSON payload.
*
* Single source of truth shared by:
* - the pg-boss `data-backup` worker that writes a backup,
* - `GET /api/admin/backups/[id]/download` that streams it back as JSON,
* - `POST /api/admin/backups/upload` that ingests an admin-supplied file,
* - `POST /api/admin/backups/[id]/restore` that re-creates DB rows from it.
*
* The schema is intentionally permissive about *extra* fields (`.passthrough`)
* so older snapshots written before a column was added still parse and so
* future minor additions don't break old admins. Required fields are
* deliberately tight — any drift surfaces as a validation error rather than
* a silent data-loss restore.
*
* `schemaVersion` is the migration handle. Bump when the on-disk shape
* changes incompatibly. The current writer (worker) historically did NOT
* include this field; `parseBackupPayload()` defaults it to "1" so legacy
* blobs continue to round-trip and the upload validator can still produce
* a useful summary for them.
*/
import { z } from "zod/v4";
export const BACKUP_SCHEMA_VERSION = "1" as const;
const isoDateTime = z
.string()
.min(1)
.refine((s) => !Number.isNaN(Date.parse(s)), {
message: "Expected ISO-8601 date-time string",
});
const measurementSchema = z
.object({
type: z.string().min(1),
value: z.number(),
unit: z.string().min(1),
measuredAt: isoDateTime,
source: z.string().min(1).optional(),
notes: z.string().nullable().optional(),
})
.passthrough();
const medicationScheduleSchema = z
.object({
windowStart: z.string().min(1),
windowEnd: z.string().min(1),
label: z.string().nullable().optional(),
dose: z.string().nullable().optional(),
})
.passthrough();
const medicationSchema = z
.object({
name: z.string().min(1),
dose: z.string(),
active: z.boolean().optional(),
schedules: z.array(medicationScheduleSchema).default([]),
})
.passthrough();
const intakeEventSchema = z
.object({
medication: z.string().min(1),
scheduledFor: isoDateTime,
takenAt: isoDateTime.nullable().optional(),
skipped: z.boolean().optional(),
source: z.string().min(1).optional(),
})
.passthrough();
const moodEntrySchema = z
.object({
date: z.string().min(1),
mood: z.string().min(1),
score: z.number().int().min(0).max(10),
tags: z.string().nullable().optional(),
source: z.string().min(1).optional(),
loggedAt: isoDateTime,
})
.passthrough();
/**
* Wire shape — exactly what the pg-boss worker writes today, plus a
* `schemaVersion` field that newer writers stamp explicitly. Older blobs
* without the field default to v1 in `parseBackupPayload`.
*/
export const backupPayloadSchema = z
.object({
schemaVersion: z.string().min(1).default(BACKUP_SCHEMA_VERSION),
exportedAt: isoDateTime,
userId: z.string().min(1),
measurements: z.array(measurementSchema).default([]),
medications: z.array(medicationSchema).default([]),
intakeEvents: z.array(intakeEventSchema).default([]),
moodEntries: z.array(moodEntrySchema).default([]),
})
.passthrough();
export type BackupPayload = z.infer<typeof backupPayloadSchema>;
/**
* Numeric counts of each backed-up record kind. Returned in the
* upload + restore API responses so the admin sees what they
* uploaded/restored without having to download the file again.
*/
export interface BackupSummary {
schemaVersion: string;
userId: string;
exportedAt: string;
measurements: number;
medications: number;
intakeEvents: number;
moodEntries: number;
}
export function summarizeBackup(payload: BackupPayload): BackupSummary {
return {
schemaVersion: payload.schemaVersion,
userId: payload.userId,
exportedAt: payload.exportedAt,
measurements: payload.measurements.length,
medications: payload.medications.length,
intakeEvents: payload.intakeEvents.length,
moodEntries: payload.moodEntries.length,
};
}
/**
* Parse a JSON blob (string or already-parsed object) against
* `backupPayloadSchema`, returning a typed payload. Throws ZodError on
* mismatch — the admin route catches it and turns it into a 422 with a
* field-level error list.
*
* Accepts both forms because:
* - the upload route hands us the parsed object after `await req.text()`
* - the restore route reads `DataBackup.data` (decrypted) which is
* always a JSON string straight from the worker.
*/
export function parseBackupPayload(input: string | unknown): BackupPayload {
const raw = typeof input === "string" ? JSON.parse(input) : input;
return backupPayloadSchema.parse(raw);
}
/**
* The schemaVersion the system understands today. Used by the upload
* route to reject inbound files written by a *future* HealthLog instance
* — restoring them under the current code might silently drop data the
* new shape carried.
*/
export function isCompatibleSchemaVersion(version: string): boolean {
return version === BACKUP_SCHEMA_VERSION;
}
@@ -0,0 +1,156 @@
/**
* Integration test for `GET /api/admin/backups/[id]/download`.
*
* Verifies:
* - 200 + content-type=application/json + content-disposition=attachment
* - the streamed body is non-empty and round-trips through
* `parseBackupPayload()` (proves we shipped the same shape we'll
* accept via the upload route).
* - 404 on unknown id (with audit-log denial entry).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
// Crypto module reads `ENCRYPTION_KEY` at first call. Vitest doesn't load
// the dev `.env`, so seed a deterministic 32-byte test key BEFORE the
// `@/lib/crypto` import below (Node binds module-level imports first, but
// `loadKeys()` is lazy — runs on first `encrypt()` call — so an env set
// here is observed in time).
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { encrypt } from "@/lib/crypto";
import { parseBackupPayload } from "@/lib/validations/backup";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function seedAdminSession() {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "backup-admin",
email: "backup-admin@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: {
userId: admin.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return admin;
}
describe("GET /api/admin/backups/[id]/download", () => {
it("streams a valid backup payload as JSON with Content-Disposition", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const payload = {
schemaVersion: "1",
exportedAt: "2026-05-09T10:00:00.000Z",
userId: admin.id,
measurements: [
{
type: "WEIGHT",
value: 82.4,
unit: "kg",
measuredAt: "2026-05-08T07:00:00.000Z",
source: "MANUAL",
notes: null,
},
],
medications: [],
intakeEvents: [],
moodEntries: [],
};
const encrypted = encrypt(JSON.stringify(payload));
const backup = await prisma.dataBackup.create({
data: {
userId: admin.id,
type: "WEEKLY_AUTO",
data: encrypted,
},
});
const { GET } = await import("@/app/api/admin/backups/[id]/download/route");
const req = new Request(
`http://localhost/api/admin/backups/${backup.id}/download`,
);
const res = await GET(req as unknown as Parameters<typeof GET>[0], {
params: Promise.resolve({ id: backup.id }),
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/json");
expect(res.headers.get("content-disposition") ?? "").toMatch(
/^attachment;\s*filename=".+\.json"$/i,
);
const text = await res.text();
expect(text.length).toBeGreaterThan(0);
const parsed = parseBackupPayload(text);
expect(parsed.userId).toBe(admin.id);
expect(parsed.measurements).toHaveLength(1);
// Audit trail: a download event must be present.
const events = await prisma.auditLog.findMany({
where: { action: "admin.backups.download" },
});
expect(events).toHaveLength(1);
expect(events[0]?.userId).toBe(admin.id);
});
it("returns 404 for an unknown id and audits the denied attempt", async () => {
const prisma = getPrismaClient();
const admin = await seedAdminSession();
const { GET } = await import("@/app/api/admin/backups/[id]/download/route");
const req = new Request(
"http://localhost/api/admin/backups/does-not-exist/download",
);
const res = await GET(req as unknown as Parameters<typeof GET>[0], {
params: Promise.resolve({ id: "does-not-exist" }),
});
expect(res.status).toBe(404);
const denied = await prisma.auditLog.findMany({
where: { action: "admin.backups.download.denied", userId: admin.id },
});
expect(denied).toHaveLength(1);
});
});