mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
fix(admin): api-tokens table truncate-with-tooltip (no scrollbar 4th attempt)
Marc has now reported a residual scrollbar on `/admin/api-tokens` four times running. Earlier fixes addressed the obvious overflow culprits (column-hide in v1.4.15, mobile card-list in v1.4.16, admin-shell mobile-strip `no-scrollbar` in v1.4.18), but the section itself still leaked a few quirks: - The mobile token-name span carried `truncate` and `break-all` on the same element. `truncate` sets `white-space: nowrap`, which beats `word-break: break-all` per CSS spec — `break-all` was dead code AND it was misleading. Remove it. - The desktop table had no per-column upper bound. A long token name could expand the row past the card on 768-1024 px viewports. Lock the layout with `table-fixed` + `<colgroup>` widths. - Permission badges had no `max-width` cap. A single long permission string (`measurements:write:audit:legacy:extended`) could push the cell past the card. Cap each badge with `max-w-full truncate` and a native `title` attribute. Wrap every potentially-long string (token name, username, permission badge) in a small `<TruncatedCell>` helper that pairs the visual truncation with a radix tooltip. The tooltip is the discoverable hover/long-press path so the full value is still readable; the native `title` attribute is a graceful fallback for screen readers and right-click → "view full name" on desktop. Adds an e2e regression that walks every element inside the mobile card-list and asserts none of them carry `scrollWidth > clientWidth`, exercised against a worst-case payload (long token name + long username + long permission string). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
088832aa39
commit
dd8212e202
3 files changed
+454
-168
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ApiTokenOverviewSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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(/<ul[^>]*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(
|
||||
/<div[^>]*\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(/<span[^>]*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"');
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={`block truncate ${className ?? ""}`}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{value}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !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.
|
||||
<EmptyState
|
||||
icon={<Key className="size-6" />}
|
||||
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
|
||||
`<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 ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !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.
|
||||
<EmptyState
|
||||
icon={<Key className="size-6" />}
|
||||
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
|
||||
`<UserManagementSection>`. `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. */}
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full table-fixed text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[18%]" />
|
||||
<col className="w-[28%]" />
|
||||
<col className="w-[24%]" />
|
||||
<col className="w-[10%]" />
|
||||
<col className="w-[12%]" />
|
||||
<col className="w-[8%]" />
|
||||
</colgroup>
|
||||
<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="max-w-0 px-3 py-2 font-medium">
|
||||
<TruncatedCell value={token.user.username} />
|
||||
</td>
|
||||
<td className="max-w-0 px-3 py-2">
|
||||
<TruncatedCell value={token.name} />
|
||||
</td>
|
||||
<td className="max-w-0 px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{token.permissions.map((p) => (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-[10px]"
|
||||
key={p}
|
||||
variant="secondary"
|
||||
className="max-w-full truncate text-xs"
|
||||
title={p}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
{p}
|
||||
</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>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{token.user.username}
|
||||
</p>
|
||||
</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 overflow-hidden rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<TruncatedCell
|
||||
value={token.name}
|
||||
className="min-w-0 flex-1 font-medium"
|
||||
/>
|
||||
{token.revoked ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
</Badge>
|
||||
) : isExpired ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-dracula-green/15 text-dracula-green shrink-0 text-[10px]">
|
||||
{t("common.active")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<TruncatedCell
|
||||
value={token.user.username}
|
||||
className="text-muted-foreground text-xs"
|
||||
/>
|
||||
</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>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{token.permissions.map((p) => (
|
||||
<Badge
|
||||
key={p}
|
||||
variant="secondary"
|
||||
className="max-w-full truncate text-[10px]"
|
||||
title={p}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user