mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
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:
1 parent
77fe256d9e
commit
277a5aa0e7
3 files changed
+291
-124
No files matched your search
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user