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

+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;
}