diff --git a/e2e/admin-api-tokens-mobile.spec.ts b/e2e/admin-api-tokens-mobile.spec.ts index 944e837b5..221738589 100644 --- a/e2e/admin-api-tokens-mobile.spec.ts +++ b/e2e/admin-api-tokens-mobile.spec.ts @@ -118,4 +118,90 @@ test.describe("/admin/api-tokens mobile no-overflow", () => { })); expect(stripDims.scrollWidth).toBeGreaterThan(stripDims.clientWidth); }); + + // v1.4.19 phase A7 (4th attempt) — even with the mobile-strip + // `no-scrollbar` fix from v1.4.18, Marc still reported a painted + // scrollbar. The card body itself must not contribute any + // horizontal-overflow culprit: every element inside the api-tokens + // section must satisfy `scrollWidth <= clientWidth + 1`. Pads with + // a long token name + long username + a long permission to exercise + // the worst case. + test("Pixel-5 viewport: nothing inside the api-tokens card overflows horizontally", async ({ + page, + }) => { + await page.route("**/api/admin/tokens", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: [ + { + id: "tok1", + name: "iOS auto-login 2026-05-05T19:42:11Z device-AABBCCDDEEFF-marathon", + permissions: [ + "measurements:write:audit:legacy:extended", + "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-that-overflows", + }, + }, + ], + error: null, + }), + }), + ); + + await page.goto("/admin/api-tokens", { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + await expect(page.getByTestId("admin-tokens-mobile-list")).toBeVisible({ + timeout: 10_000, + }); + + // Walk every element inside the mobile card-list and assert no + // child has a wider scrollWidth than its container. This is the + // Pixel-5 worst-case Marc kept hitting. + const culprits = await page + .getByTestId("admin-tokens-mobile-list") + .evaluate((root) => { + const out: Array<{ + tag: string; + klass: string; + scrollWidth: number; + clientWidth: number; + }> = []; + for (const el of root.querySelectorAll("*")) { + if (el.scrollWidth > el.clientWidth + 1) { + out.push({ + tag: el.tagName, + klass: + typeof el.className === "string" + ? el.className.slice(0, 80) + : "", + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth, + }); + } + } + return out; + }); + expect( + culprits, + `overflow culprits: ${JSON.stringify(culprits, null, 2)}`, + ).toEqual([]); + + // Document-level horizontal overflow — the page must not paint + // any horizontal scroll, period. + const dims = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + innerWidth: window.innerWidth, + })); + expect(dims.scrollWidth).toBeLessThanOrEqual(dims.innerWidth + 1); + }); }); diff --git a/src/components/admin/__tests__/api-token-truncate.test.tsx b/src/components/admin/__tests__/api-token-truncate.test.tsx new file mode 100644 index 000000000..1aabe41d2 --- /dev/null +++ b/src/components/admin/__tests__/api-token-truncate.test.tsx @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.4.19 A7 — `/admin/api-tokens` 4th-attempt scrollbar fix. + * + * Marc reported a "minimaler Scrollbar" still painting at the + * bottom-right of the api-tokens page, even after the v1.4.18 phase + * A2 mobile-strip `no-scrollbar` fix. The remaining culprits are + * inside the section card itself: long token names, long usernames, + * and long permission strings can each push their parent past the + * viewport. Cumulatively this leaves the page in an "almost-fits" + * state where the visible scrollbar Marc sees is the page-level main + * scroll picking up a fraction of pixels of horizontal scroll spill + * from the children. + * + * The fix is aggressive: every cell that could carry a long string + * gets `truncate` (overflow-hidden + text-ellipsis + whitespace-nowrap) + * AND a tooltip on hover/long-press so the full value is still + * discoverable. This is the same pattern UserManagementSection uses + * — apply it consistently here. + */ + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/api-tokens", +})); + +const sampleTokens = [ + { + id: "tok1", + name: "iOS auto-login 2026-05-05T19:42:11Z 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-that-overflows", + }, + }, +]; + +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 — truncate + tooltip (4th attempt)", () => { + it("mobile token-name span does NOT carry the contradictory `break-all` modifier", () => { + // `truncate` already sets `white-space: nowrap`, which beats + // `word-break: break-all` per CSS spec — `break-all` was dead + // code AND it is misleading: a future reader expects the long + // name to break onto multiple lines, when in fact it stays on a + // single ellipsised line. Strip the conflicting class. + const html = render(); + const mobileMatch = html.match(/]*md:hidden[^>]*>([\s\S]*?)<\/ul>/); + expect(mobileMatch).not.toBeNull(); + expect(mobileMatch![1]).not.toMatch(/\btruncate\b[^"]*\bbreak-all\b/); + }); + + it("desktop token-name and user cells truncate inside max-width wrappers so a long string cannot push the row past the viewport", () => { + const html = render(); + // Desktop table block is `hidden md:block`. Its rows render at + // server-side render unconditionally (the visibility is purely + // CSS), so we can grep for the cell contents in the SSR output. + const desktopMatch = html.match( + /]*\bhidden\b[^>]*md:block[^>]*>([\s\S]*?)<\/table>/, + ); + expect(desktopMatch).not.toBeNull(); + const desktop = desktopMatch![1]; + // Token name and username cells must each carry the truncate + // modifier with a bounded max-width so they cannot expand the + // row past the available column width on any viewport. + expect(desktop).toMatch(/\btruncate\b/); + expect(desktop).toMatch(/\bmax-w-/); + }); + + it("permission badges carry max-width + truncate so a single long permission string cannot blow up the cell", () => { + const html = render(); + // Even with `flex-wrap`, a single permission like + // "measurements:write:audit:legacy:..." can be wider than the + // card on mobile. Badge primitive already has `overflow-hidden`, + // we add an upper bound on width so it ellipsises instead of + // pushing the parent. + expect(html).toMatch(/]*max-w-(?:\[[^\]]+\]|full)[^>]*\btruncate\b/); + }); + + it("renders shadcn tooltip wrappers around truncated cells so the full value stays discoverable", () => { + const html = render(); + // Tooltip primitive uses `data-slot="tooltip-content"` for the + // hover bubble. Verify at least one tooltip-content node was + // rendered next to a truncated cell. + expect(html).toContain('data-slot="tooltip-trigger"'); + }); +}); diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index 26bb17d82..d8fac1314 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -6,10 +6,60 @@ import { ChevronDown, Key, Loader2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { type ApiTokenInfo } from "./_shared"; +/** + * Wraps a string in `truncate` + tooltip so a long token name / + * username / permission cannot push the row past its bounds. + * + * v1.4.19 phase A7 (4th attempt) — earlier fixes (column-hide, + * mobile card-list, mobile-strip `no-scrollbar`) cleared every + * scrollbar Marc reported except a residual painted bar he kept + * seeing at the bottom-right. Production probe confirmed no + * remaining horizontal-overflow culprits, but the cards still + * carried `break-all` next to `truncate` (dead code; `white-space: + * nowrap` from `truncate` beats `word-break: break-all`) and the + * desktop table cells had no upper-bound on width — a long token + * name on a 768-1024 px viewport could spill into the next column. + * + * Switching every potentially-long cell to a hover tooltip means + * the visible row stays inside the card no matter what the data + * looks like. The native `title` attribute is a graceful fallback + * for screen readers and right-click → "view full name" on desktop; + * the radix tooltip handles hover and keyboard focus. + */ +function TruncatedCell({ + value, + className, +}: { + value: string; + className?: string; +}) { + return ( + + + + + {value} + + + {value} + + + ); +} + export function ApiTokenOverviewSection() { const { t } = useTranslations(); // v1.5 phase-4b moved this to a dedicated route (`/admin/api-tokens`), @@ -50,180 +100,197 @@ export function ApiTokenOverviewSection() { {expanded && (
{isLoading ? ( -
- -
- ) : !tokens?.length ? ( - // v1.4.15 phase-C5: empty state explains where tokens come - // from (native client sign-in / Settings → Account) so a - // fresh admin doesn't think they need to create them here. - } - title={t("admin.tokensEmptyTitle")} - description={t("admin.tokensEmptyDescription")} - /> - ) : ( - <> - {/* 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 ? ( +
    + +
    + ) : !tokens?.length ? ( + // v1.4.15 phase-C5: empty state explains where tokens come + // from (native client sign-in / Settings → Account) so a + // fresh admin doesn't think they need to create them here. + } + title={t("admin.tokensEmptyTitle")} + description={t("admin.tokensEmptyDescription")} + /> + ) : ( + <> + {/* 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 + ``. `table-fixed` + per-column + widths give every cell a hard upper bound so a long + token name cannot expand the row past the card. 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.permissions.map((p) => ( - {t("settings.tokenRevoked")} + {p} - ) : isExpired ? ( - - {t("settings.tokenExpired")} - - ) : ( - - {t("common.active")} - - )} + ))}
    -

    - {token.user.username} -

    +
    + {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.revoked ? ( + + {t("settings.tokenRevoked")} + + ) : isExpired ? ( + + {t("settings.tokenExpired")} + + ) : ( + + {t("common.active")} + + )}
      +
      -
      - {token.permissions.map((p) => ( - - {p} - - ))} -
      -

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

      -

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

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

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

    +

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

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