From 277a5aa0e7d1c99d8c5813369909467b9d7a8508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 23:25:34 +0200 Subject: [PATCH] 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 `` pattern instead: gate the desktop `` behind `hidden md:block` and render a real mobile card-list at 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
    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) --- e2e/admin-api-tokens-mobile.spec.ts | 77 ++++++ .../api-token-overview-responsive.test.tsx | 85 +++--- .../admin/api-token-overview-section.tsx | 253 +++++++++++------- 3 files changed, 291 insertions(+), 124 deletions(-) create mode 100644 e2e/admin-api-tokens-mobile.spec.ts diff --git a/e2e/admin-api-tokens-mobile.spec.ts b/e2e/admin-api-tokens-mobile.spec.ts new file mode 100644 index 000000000..2dc10878d --- /dev/null +++ b/e2e/admin-api-tokens-mobile.spec.ts @@ -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 `
` for a + * mobile card-list at `. + * + * 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); + }); +}); diff --git a/src/components/admin/__tests__/api-token-overview-responsive.test.tsx b/src/components/admin/__tests__/api-token-overview-responsive.test.tsx index f9c2ef6e6..b79131265 100644 --- a/src/components/admin/__tests__/api-token-overview-responsive.test.tsx +++ b/src/components/admin/__tests__/api-token-overview-responsive.test.tsx @@ -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 + * `` pattern: the desktop `
` is gated + * behind `hidden md:block` and a real card-list renders at ({ @@ -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 (
never paints (and never tries to scroll) on phones. + expect(html).toMatch(/]*\bhidden\b[^>]*md:block[^>]*>[\s\S]*
{ + it("renders a mobile card-list at { const html = render(); - //
for user column carries the hidden-then-table-cell pattern. - expect(html).toMatch(/]*hidden[^>]*sm:table-cell[^>]*>/); + // The mobile fallback is a
    tagged with `md:hidden` and a + // stable test id. Mirrors ``'s pattern. + expect(html).toContain('data-testid="admin-tokens-mobile-list"'); + expect(html).toMatch(/]*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
      . + const mobileMatch = html.match( + /]*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( + /]*md:hidden[^>]*>([\s\S]*?)<\/ul>/, + ); + expect(mobileMatch).not.toBeNull(); + // The Last-used + Created lines use

      — no + // whitespace-nowrap so a long German date+time can flow onto a + // second line within the card. The shadcn `` 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( + /]*text-\[11px\][^>]*>(?![^<]*whitespace-nowrap)/, + ); + }); }); diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index 0cf5b9ce0..26bb17d82 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -28,7 +28,7 @@ export function ApiTokenOverviewSection() { }); return ( -

      +
      @@ -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. -
      - - - - - - - - - - - - - {tokens.map((token, i) => { - const isExpired = - token.expiresAt && new Date(token.expiresAt) < new Date(); - return ( - - - - - - - - - ); - })} - -
      - {t("admin.tokenUser")} - - {t("admin.tokenName")} - - {t("admin.tokenPermissions")} - - {t("admin.tokenStatus")} - - {t("admin.tokenLastUsed")} - - {t("admin.tokenCreated")} -
      - {token.user.username} - -
      - {token.name} - {/* On mobile the user column is hidden, so - surface the username inline under the - token name instead of dropping it. */} - - {token.user.username} - -
      -
      -
      - {token.permissions.map((p) => ( - - {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 + ``. The inner `overflow-x-auto` + is a belt-and-suspenders guard for medium viewports + with extra-long permission lists. */} +
      + + + + + + + + + + + + + {tokens.map((token, i) => { + const isExpired = + token.expiresAt && + new Date(token.expiresAt) < new Date(); + return ( + + + + + + + + + ); + })} + +
      + {t("admin.tokenUser")} + + {t("admin.tokenName")} + + {t("admin.tokenPermissions")} + + {t("admin.tokenStatus")} + + {t("admin.tokenLastUsed")} + + {t("admin.tokenCreated")} +
      + {token.user.username} + {token.name} +
      + {token.permissions.map((p) => ( + + {p} + + ))} +
      +
      + {token.revoked ? ( + + {t("settings.tokenRevoked")} - ))} + ) : isExpired ? ( + + {t("settings.tokenExpired")} + + ) : ( + + {t("common.active")} + + )} + + {token.lastUsedAt + ? formatDateTime(token.lastUsedAt) + : t("admin.tokenNeverUsed")} + + {formatDate(token.createdAt)} +
      +
      + + {/* 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 ``. */} +
        + {tokens.map((token) => { + const isExpired = + token.expiresAt && new Date(token.expiresAt) < new Date(); + return ( +
      • +
        +
        +
        + + {token.name} + + {token.revoked ? ( + + {t("settings.tokenRevoked")} + + ) : isExpired ? ( + + {t("settings.tokenExpired")} + + ) : ( + + {t("common.active")} + + )}
        -
      - {token.revoked ? ( - - {t("settings.tokenRevoked")} - - ) : isExpired ? ( - - {t("settings.tokenExpired")} - - ) : ( - - {t("common.active")} - - )} - - {token.lastUsedAt - ? formatDateTime(token.lastUsedAt) - : t("admin.tokenNeverUsed")} - - {formatDate(token.createdAt)} -
      -
      +

      + {token.user.username} +

      +
      +
      +
      + {token.permissions.map((p) => ( + + {p} + + ))} +
      +

      + {t("admin.tokenLastUsed")}:{" "} + {token.lastUsedAt + ? formatDateTime(token.lastUsedAt) + : t("admin.tokenNeverUsed")} +

      +

      + {t("admin.tokenCreated")}: {formatDate(token.createdAt)} +

      + + ); + })} +
    + )} )}