"use client"; import { useQuery } from "@tanstack/react-query"; import { Key, Loader2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { formatDate, formatDateTime } from "@/lib/format"; import type { Formatters } from "@/lib/format-locale"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ListRow } from "@/components/ui/list-row"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type ApiTokenInfo } from "./_shared"; import { apiGet } from "@/lib/api/api-fetch"; /** * 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 the maintainer reported except a residual painted bar they 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} ); } /** * v1.4.22 W5 reconcile (S-03) — the revoked / isExpired / active * 3-way ternary is identical between the desktop table and the * mobile card list; the only difference is `text-xs` vs `text-[10px]`. * Centralise so the two surfaces stay in lock-step. */ function TokenStatusBadge({ token, size, }: { token: ApiTokenInfo; size: "sm" | "xs"; }) { const { t } = useTranslations(); const expired = token.expiresAt != null && new Date(token.expiresAt) < new Date(); const sizeClass = size === "xs" ? "text-[10px]" : "text-xs"; if (token.revoked) { return ( {t("settings.tokenRevoked")} ); } if (expired) { return ( {t("settings.tokenExpired")} ); } return ( {t("common.active")} ); } /** * F-18 (v1.4.19): the auto-login flow names tokens * "web auto-login 2026-05-05T19:46:20.603Z" / "iOS auto-login …" by * suffixing the issuing call's `Date.now().toISOString()`. That suffix * makes the admin list look like debug output. Reformat to * "iOS auto-login · 05.05.2026 19:46" while leaving any non-ISO suffix * (manual names, device fingerprints, etc.) untouched. */ // v1.4.22 D / D-CR-M-03 — broaden the ISO match to include non-Z // offsets (e.g. `+02:00`, `-05:30`). The previous regex only matched // UTC (`Z`), so a token name carrying a local-tz timestamp slipped // through unformatted and rendered the raw `2026-05-05T19:46:20.603+02:00` // suffix instead of the locale-aware `05.05.2026 21:46` chunk. const TOKEN_NAME_ISO_RE = /^(.+?)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))(.*)$/; /** * Issue #66 (date-format sweep) — this used to hard-code `de-DE` + * `Europe/Berlin`, so a non-German admin saw a German-formatted * timestamp baked into the token name regardless of their own locale * or profile timezone. Routes through the same `fmt.dateTime` every * other surface renders through, so the admin's locale, hour-cycle * preference, and profile timezone all apply here too. */ function formatTokenName(name: string, fmt: Formatters): string { const match = TOKEN_NAME_ISO_RE.exec(name); if (!match) return name; const [, prefix, iso, rest] = match; const d = new Date(iso); if (Number.isNaN(d.getTime())) return name; // `fmt.dateTime` emits "05.05.2026, 21:46" — strip the comma so the // suffix reads as a single date+time chunk. const stamp = fmt.dateTime(d).replace(",", ""); const formatted = `${prefix} · ${stamp}`; return rest && rest.trim() ? `${formatted} ${rest.trim()}` : formatted; } export function ApiTokenOverviewSection() { const { t } = useTranslations(); const fmt = useFormatters(); // v1.4.19 phase A7 — `/admin/api-tokens` is a dedicated single- // section route. The previous pattern carried a "Collapse / Expand" // toggle as an escape hatch from the v1.4 shared admin page where // 13 sections lived together, but on a route that only renders this // one card the toggle hides the entire surface. the maintainer reported it // as "sinnlos" — gone. const { data: tokens, isLoading } = useQuery({ queryKey: queryKeys.adminTokens(), queryFn: async () => { return apiGet("/api/admin/tokens"); }, }); return (
{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 ``. `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) => { return ( {/* v1.4.22 C2 (5th attempt) — drop `whitespace-nowrap` on the date cells. The v1.4.19 A7 probe confirmed `formatDateTime` renders "05.05.2026, 21:46" (~110px) which exceeds the 12% `` allotment (~84px on a 700px content area); `whitespace-nowrap` wins over `table-fixed`'s width contract, so the table's intrinsic width exceeds 100% and the wrapper's `overflow-x-auto` paints the scrollbar the maintainer kept reporting for the 5th time. Letting the date+time wrap to two lines on narrow viewports costs one row of height but eliminates the bar. */} ); })}
{t("admin.tokenUser")} {t("admin.tokenName")} {t("admin.tokenPermissions")} {t("admin.tokenStatus")} {t("admin.tokenLastUsed")} {t("admin.tokenCreated")}
{token.permissions.map((p) => ( {p === "*" ? t("admin.tokenPermissionAll") : p} ))}
{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) => { return (
  • {token.permissions.map((p) => ( {p === "*" ? t("admin.tokenPermissionAll") : p} ))}

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

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

  • ); })}
)}
); }