From dddc18c1b1fde7254392f2f28440d163ed253b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 20:24:32 +0200 Subject: [PATCH] fix(admin): make api-tokens table responsive on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/admin/api-tokens` table 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 — a clear violation of the v1.4.x mobile-viewport contract guarded by `e2e/mobile-viewport.spec.ts`. Hide three lower-priority columns at narrow widths and surface the hidden user inline so no information is lost: - User column: `hidden sm:table-cell` (resurfaces ≥640px). On mobile the username paints under the token name as a small secondary line. - Last-used column: `hidden md:table-cell` (resurfaces ≥768px). - Created column: `hidden md:table-cell`. - Card padding shrinks from `p-6` → `p-4 sm:p-6` to claw back 16 CSS px on each side at mobile widths. The `
` wrapper stays as a belt-and-suspenders guard for ultra-narrow viewports where even the trimmed three-column layout might overflow (very long permission lists or long token names). Without it the table would push the card itself wider than the viewport. Add `api-token-overview-responsive.test.tsx` to lock the contract: the user / last-used / created columns must keep their `hidden + (sm|md):table-cell` classes, the inline mobile fallback span (`sm:hidden`) must duplicate the username so hiding the column doesn't drop data, and the card root must keep the responsive `p-4 sm:p-6` padding. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api-token-overview-responsive.test.tsx | 115 ++++++++++++++++++ .../admin/api-token-overview-section.tsx | 36 ++++-- 2 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 src/components/admin/__tests__/api-token-overview-responsive.test.tsx diff --git a/src/components/admin/__tests__/api-token-overview-responsive.test.tsx b/src/components/admin/__tests__/api-token-overview-responsive.test.tsx new file mode 100644 index 000000000..f9c2ef6e6 --- /dev/null +++ b/src/components/admin/__tests__/api-token-overview-responsive.test.tsx @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.4.15 phase A2 — `/admin/api-tokens` table responsive guard. + * + * 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. + */ + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/api-tokens", +})); + +const sampleTokens = [ + { + id: "tok1", + name: "iOS app", + permissions: ["*"], + lastUsedAt: "2026-05-08T12:00:00Z", + expiresAt: null, + createdAt: "2026-05-01T08:00:00Z", + revoked: false, + user: { id: "u1", username: "marc" }, + }, +]; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: sampleTokens, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "marc", + email: "marc@example.com", + role: "ADMIN", + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ApiTokenOverviewSection } from "../api-token-overview-section"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("ApiTokenOverviewSection — responsive", () => { + it("wraps the table in an overflow-x-auto container", () => { + const html = render(); + expect(html).toContain("overflow-x-auto"); + }); + + it("hides the user column until sm:", () => { + const html = render(); + // for user column carries the hidden-then-table-cell pattern. + expect(html).toMatch(/]*hidden[^>]*sm:table-cell[^>]*>/); + }); + + it("hides the last-used and created columns until md:", () => { + 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); + }); + + it("surfaces the username inline under the token name on mobile", () => { + 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); + }); + + 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. + expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/); + }); +}); diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index 1d49726e7..03c0ae17b 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -27,7 +27,7 @@ export function ApiTokenOverviewSection() { }); return ( -
+
@@ -57,11 +57,17 @@ export function ApiTokenOverviewSection() { {t("admin.noTokens")}

) : ( -
- + // 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. +
+
- - - @@ -90,10 +96,20 @@ export function ApiTokenOverviewSection() { key={token.id} className={i % 2 === 0 ? "bg-muted/30" : ""} > - - + - -
+ {t("admin.tokenUser")} @@ -73,10 +79,10 @@ export function ApiTokenOverviewSection() { {t("admin.tokenStatus")} + {t("admin.tokenLastUsed")} + {t("admin.tokenCreated")}
+ {token.user.username} {token.name} +
+ {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) => ( @@ -122,12 +138,12 @@ export function ApiTokenOverviewSection() { )}
+ {token.lastUsedAt ? formatDateTime(token.lastUsedAt) : t("admin.tokenNeverUsed")} + {formatDate(token.createdAt)}