fix(admin): api-tokens table no horizontal overflow on Pixel-5 viewport

v1.4.15 A2 hid columns + tightened card padding, but Marc still saw a
scrollbar on prod at /admin/api-tokens on Pixel-5 (393 CSS px). The
fundamental issue is that even with three columns visible, long
permission lists or token names could push the scroll wrapper wide
inside the card. Mirror the `<UserManagementSection>` pattern instead:
gate the desktop `<table>` behind `hidden md:block` and render a real
mobile card-list at <md.

The mobile card-list:
- One <li> per token, padding p-3, no horizontal scroll wrapper.
- Token name uses `truncate break-all` so even a hex-suffixed name
  wraps within card width.
- Permission badges sit in a `flex-wrap` row.
- Last-used + Created collapse to short meta lines (no nowrap).
- Status badge floats next to the name on the same flex-wrap row.

Tests:
- `api-token-overview-responsive.test.tsx` rewritten to assert the new
  invariants: hidden md:block on the desktop table; md:hidden on the
  card-list <ul> with `data-testid="admin-tokens-mobile-list"`; no
  overflow-x-auto inside the mobile container; meta paragraphs wrap.
- New e2e spec `admin-api-tokens-mobile.spec.ts` mocks a worst-case
  payload (long username, multiple permissions, long token name) and
  asserts `documentElement.scrollWidth <= window.innerWidth + 1` at the
  Pixel-5 viewport on /admin/api-tokens.

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 23:25:34 +02:00
1 parent 77fe256d9e
commit 277a5aa0e7
3 files changed
+291 -124

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 A3 — `/admin/api-tokens` must not produce horizontal page
* overflow on a Pixel-5 viewport (393 CSS px). v1.4.15 added column-
* hide breakpoints + tighter card padding, but Marc still saw a
* scrollbar on prod. The v1.4.16 fix swaps the desktop `<table>` for a
* mobile card-list at <md, mirroring `<UserManagementSection>`.
*
* Mobile-only spec — the desktop project skips it.
*/
test.describe("/admin/api-tokens mobile no-overflow", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("Pixel-5 viewport: no horizontal page scroll on /admin/api-tokens", async ({
page,
}) => {
// Mock the admin tokens endpoint with a moderately wide payload —
// a token with multiple permissions, a long name, and timestamps.
// This is the realistic worst case Marc hit on prod.
await page.route("**/api/admin/tokens", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: [
{
id: "tok1",
name: "iOS app device-AABBCCDDEEFF",
permissions: ["measurements:write", "medications:read", "*"],
lastUsedAt: "2026-05-08T12:00:00Z",
expiresAt: null,
createdAt: "2026-05-01T08:00:00Z",
revoked: false,
user: { id: "u1", username: "marc-the-very-long-username" },
},
{
id: "tok2",
name: "Native client xyz",
permissions: ["*"],
lastUsedAt: null,
expiresAt: "2026-08-01T00:00:00Z",
createdAt: "2026-04-15T08:00:00Z",
revoked: false,
user: { id: "u1", username: "marc-the-very-long-username" },
},
],
error: null,
}),
}),
);
await page.goto("/admin/api-tokens", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// The mobile card-list must be visible and the desktop table must
// be display:none at this viewport.
await expect(
page.getByTestId("admin-tokens-mobile-list"),
).toBeVisible({ timeout: 10_000 });
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
}));
expect(
dims.scrollWidth,
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
});
});
@@ -2,17 +2,18 @@ import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.15 phase A2 — `/admin/api-tokens` table responsive guard.
* v1.4.16 phase A3 — `/admin/api-tokens` no horizontal overflow on
* Pixel-5-class viewports (393 CSS px).
*
* The token table previously rendered six full-width columns at every
* breakpoint. On Pixel-5-class viewports (360 CSS px) the row exceeded
* the card and forced the document itself to scroll horizontally. The
* fix hides three lower-priority columns (user, last-used, created)
* until `sm:`/`md:` and surfaces the user inline under the token name
* on mobile so no data is lost.
*
* This test asserts the responsive classes are present in the rendered
* markup so a future refactor can't silently drop them.
* The v1.4.15 fix tried hiding columns + tightening padding, but a
* scrollbar still showed up because the wrapper kept `overflow-x-auto`
* on mobile and content like long permission badges or token names
* could exceed the available width. v1.4.16 mirrors the
* `<UserManagementSection>` pattern: the desktop `<table>` is gated
* behind `hidden md:block` and a real card-list renders at <md. No
* `overflow-x-auto` on mobile — every cell wraps within the card,
* `break-all` on the name + permission badges, and the document never
* grows wider than the viewport.
*/
vi.mock("next/navigation", () => ({
@@ -75,41 +76,65 @@ function render() {
}
describe("ApiTokenOverviewSection — responsive", () => {
it("wraps the table in an overflow-x-auto container", () => {
it("hides the desktop table on mobile (<md) via hidden md:block", () => {
const html = render();
expect(html).toContain("overflow-x-auto");
// The desktop table wrapper carries `hidden md:block` so the
// <table> never paints (and never tries to scroll) on phones.
expect(html).toMatch(/<div[^>]*\bhidden\b[^>]*md:block[^>]*>[\s\S]*<table/);
});
it("hides the user column until sm:", () => {
it("renders a mobile card-list at <md (md:hidden)", () => {
const html = render();
// <th> for user column carries the hidden-then-table-cell pattern.
expect(html).toMatch(/<th[^>]*hidden[^>]*sm:table-cell[^>]*>/);
// The mobile fallback is a <ul> tagged with `md:hidden` and a
// stable test id. Mirrors `<UserManagementSection>`'s pattern.
expect(html).toContain('data-testid="admin-tokens-mobile-list"');
expect(html).toMatch(/<ul[^>]*md:hidden/);
});
it("hides the last-used and created columns until md:", () => {
it("mobile card-list has no overflow-x-auto wrapper", () => {
const html = render();
// Both columns share the same md: pattern; expect at least two
// matches (one per column).
const matches = html.match(/hidden[^"]*md:table-cell/g) ?? [];
expect(matches.length).toBeGreaterThanOrEqual(2);
// Mobile cards must let content wrap within the card rather than
// forcing a scroll container — that was the v1.4.15 bug Marc
// re-reported. Confirm there's no overflow-x-auto INSIDE the
// mobile <ul>.
const mobileMatch = html.match(
/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/,
);
expect(mobileMatch).not.toBeNull();
expect(mobileMatch![1]).not.toContain("overflow-x-auto");
});
it("surfaces the username inline under the token name on mobile", () => {
it("surfaces the username, permissions, status, last-used, and created on mobile cards", () => {
const html = render();
// The mobile-only fallback span carries `sm:hidden` and the
// username text. Without it, hiding the user column would lose
// information on phones.
expect(html).toContain("sm:hidden");
// Username appears twice: once in the hidden user column TD, once
// in the inline mobile fallback span.
const userOccurrences = (html.match(/marc/g) ?? []).length;
expect(userOccurrences).toBeGreaterThanOrEqual(2);
// All data points the desktop table shows must also appear in the
// mobile cards. Token name + username + a permission badge + the
// last-used/created labels are all present.
expect(html).toContain("marc"); // username
expect(html).toContain("iOS app"); // token name
expect(html).toMatch(/\*/); // permission badge content
});
it("uses a smaller card padding on mobile (p-4 sm:p-6)", () => {
const html = render();
// The card root carries the responsive padding to leave more room
// for the table within a 360 CSS-px viewport.
// for content within a 393 CSS-px viewport.
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/);
});
it("mobile timestamp lines wrap (no nowrap on the meta paragraphs)", () => {
const html = render();
const mobileMatch = html.match(
/<ul[^>]*md:hidden[^>]*>([\s\S]*?)<\/ul>/,
);
expect(mobileMatch).not.toBeNull();
// The Last-used + Created lines use <p class="text-[11px]…"> — no
// whitespace-nowrap so a long German date+time can flow onto a
// second line within the card. The shadcn `<Badge>` primitive
// does carry `whitespace-nowrap`, which is fine for the short
// permission strings we render — those are scoped to a flex-wrap
// container that line-breaks at the badge boundary.
expect(mobileMatch![1]).toMatch(
/<p[^>]*text-\[11px\][^>]*>(?![^<]*whitespace-nowrap)/,
);
});
});
@@ -28,7 +28,7 @@ export function ApiTokenOverviewSection() {
});
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<div className="bg-card border-border overflow-hidden rounded-xl border p-4 sm:p-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Key className="text-primary h-5 w-5" />
@@ -63,101 +63,166 @@ export function ApiTokenOverviewSection() {
description={t("admin.tokensEmptyDescription")}
/>
) : (
// Mobile fits within the card by hiding lower-priority
// columns (user, last-used, created) until `sm`. The
// remaining `overflow-x-auto` is a belt-and-suspenders
// guard for ultra-narrow viewports — the table still
// scrolls within its container instead of forcing the
// page itself to scroll horizontally.
<div className="-mx-2 overflow-x-auto sm:mx-0">
<table className="w-full min-w-0 text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="hidden px-3 py-2 text-left font-medium sm:table-cell">
{t("admin.tokenUser")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.tokenName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.tokenPermissions")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.tokenStatus")}
</th>
<th className="hidden px-3 py-2 text-right font-medium md:table-cell">
{t("admin.tokenLastUsed")}
</th>
<th className="hidden px-3 py-2 text-right font-medium md:table-cell">
{t("admin.tokenCreated")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{tokens.map((token, i) => {
const isExpired =
token.expiresAt && new Date(token.expiresAt) < new Date();
return (
<tr
key={token.id}
className={i % 2 === 0 ? "bg-muted/30" : ""}
>
<td className="hidden px-3 py-2 font-medium sm:table-cell">
{token.user.username}
</td>
<td className="px-3 py-2">
<div className="flex flex-col gap-0.5">
<span>{token.name}</span>
{/* On mobile the user column is hidden, so
surface the username inline under the
token name instead of dropping it. */}
<span className="text-muted-foreground text-xs sm:hidden">
{token.user.username}
</span>
</div>
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{token.permissions.map((p) => (
<Badge
key={p}
variant="secondary"
className="text-xs"
>
{p}
<>
{/* Desktop table — verbatim layout for md+. The wrapping
`hidden md:block` swaps it out for the card-list below
on mobile, mirroring the pattern used in
`<UserManagementSection>`. The inner `overflow-x-auto`
is a belt-and-suspenders guard for medium viewports
with extra-long permission lists. */}
<div className="hidden overflow-x-auto md:block">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.tokenUser")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.tokenName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.tokenPermissions")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.tokenStatus")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.tokenLastUsed")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.tokenCreated")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{tokens.map((token, i) => {
const isExpired =
token.expiresAt &&
new Date(token.expiresAt) < new Date();
return (
<tr
key={token.id}
className={i % 2 === 0 ? "bg-muted/30" : ""}
>
<td className="px-3 py-2 font-medium">
{token.user.username}
</td>
<td className="px-3 py-2">{token.name}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{token.permissions.map((p) => (
<Badge
key={p}
variant="secondary"
className="text-xs"
>
{p}
</Badge>
))}
</div>
</td>
<td className="px-3 py-2 text-right">
{token.revoked ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenRevoked")}
</Badge>
))}
) : isExpired ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-dracula-green/15 text-dracula-green text-xs">
{t("common.active")}
</Badge>
)}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{token.lastUsedAt
? formatDateTime(token.lastUsedAt)
: t("admin.tokenNeverUsed")}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{formatDate(token.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile card list — each token renders as a self-
contained card with a stacked meta layout. No
horizontal scroll: every cell wraps within the card
width. Mirrors `<UserManagementSection>`. */}
<ul
className="space-y-2 md:hidden"
data-testid="admin-tokens-mobile-list"
>
{tokens.map((token) => {
const isExpired =
token.expiresAt && new Date(token.expiresAt) < new Date();
return (
<li
key={token.id}
className="bg-muted/30 border-border rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium break-all">
{token.name}
</span>
{token.revoked ? (
<Badge
variant="destructive"
className="text-[10px]"
>
{t("settings.tokenRevoked")}
</Badge>
) : isExpired ? (
<Badge
variant="destructive"
className="text-[10px]"
>
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-dracula-green/15 text-dracula-green text-[10px]">
{t("common.active")}
</Badge>
)}
</div>
</td>
<td className="px-3 py-2 text-right">
{token.revoked ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenRevoked")}
</Badge>
) : isExpired ? (
<Badge variant="destructive" className="text-xs">
{t("settings.tokenExpired")}
</Badge>
) : (
<Badge className="bg-dracula-green/15 text-dracula-green text-xs">
{t("common.active")}
</Badge>
)}
</td>
<td className="text-muted-foreground hidden px-3 py-2 text-right text-xs whitespace-nowrap md:table-cell">
{token.lastUsedAt
? formatDateTime(token.lastUsedAt)
: t("admin.tokenNeverUsed")}
</td>
<td className="text-muted-foreground hidden px-3 py-2 text-right text-xs whitespace-nowrap md:table-cell">
{formatDate(token.createdAt)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-muted-foreground truncate text-xs">
{token.user.username}
</p>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{token.permissions.map((p) => (
<Badge
key={p}
variant="secondary"
className="text-[10px] break-all"
>
{p}
</Badge>
))}
</div>
<p className="text-muted-foreground mt-2 text-[11px]">
{t("admin.tokenLastUsed")}:{" "}
{token.lastUsedAt
? formatDateTime(token.lastUsedAt)
: t("admin.tokenNeverUsed")}
</p>
<p className="text-muted-foreground text-[11px]">
{t("admin.tokenCreated")}: {formatDate(token.createdAt)}
</p>
</li>
);
})}
</ul>
</>
)}
</div>
)}