fix(admin): make api-tokens table responsive on mobile

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 `<div className="overflow-x-auto">` 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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 20:24:32 +02:00
1 parent 73afae0314
commit dddc18c1b1
2 files changed
+141 -10

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">
<ApiTokenOverviewSection />
</I18nProvider>,
);
}
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();
// <th> for user column carries the hidden-then-table-cell pattern.
expect(html).toMatch(/<th[^>]*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/);
});
});
@@ -27,7 +27,7 @@ export function ApiTokenOverviewSection() {
});
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="bg-card border-border 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" />
@@ -57,11 +57,17 @@ export function ApiTokenOverviewSection() {
{t("admin.noTokens")}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
// 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="px-3 py-2 text-left font-medium">
<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">
@@ -73,10 +79,10 @@ export function ApiTokenOverviewSection() {
<th className="px-3 py-2 text-right font-medium">
{t("admin.tokenStatus")}
</th>
<th className="px-3 py-2 text-right font-medium">
<th className="hidden px-3 py-2 text-right font-medium md:table-cell">
{t("admin.tokenLastUsed")}
</th>
<th className="px-3 py-2 text-right font-medium">
<th className="hidden px-3 py-2 text-right font-medium md:table-cell">
{t("admin.tokenCreated")}
</th>
</tr>
@@ -90,10 +96,20 @@ export function ApiTokenOverviewSection() {
key={token.id}
className={i % 2 === 0 ? "bg-muted/30" : ""}
>
<td className="px-3 py-2 font-medium">
<td className="hidden px-3 py-2 font-medium sm:table-cell">
{token.user.username}
</td>
<td className="px-3 py-2">{token.name}</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) => (
@@ -122,12 +138,12 @@ export function ApiTokenOverviewSection() {
</Badge>
)}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
<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 px-3 py-2 text-right text-xs whitespace-nowrap">
<td className="text-muted-foreground hidden px-3 py-2 text-right text-xs whitespace-nowrap md:table-cell">
{formatDate(token.createdAt)}
</td>
</tr>