mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(settings): show a read failure on the API-token list, not an empty state
Same fall-through as the share-link list on another security surface: the token query had no error or loading branch, so a failed read rendered "no active tokens". Gate the list on the query state with QueryErrorCard and retry, a skeleton while loading. Test watched fail first against the old empty-state fall-through.
This commit is contained in:
1 parent
53a463f11b
commit
ab2c5cb2aa
2 files changed
+353
-271
No files matched your search
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* A read failure on the API-token list must not read as "no active tokens".
|
||||
* `useQuery` is forced into its error state; the card has to render the
|
||||
* `query-error-card` slot with retry and must never fall through to the empty
|
||||
* state — the same §6 fall-through the share-link surface had, on another
|
||||
* security surface.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/api",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
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: "testuser", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ApiSection } from "../api-section";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ApiSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ApiSection — a token-read failure is honest, not empty", () => {
|
||||
it("renders the query-error card with retry and never the empty state", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain('data-slot="query-error-retry"');
|
||||
expect(html).not.toContain('data-testid="settings-api-tokens-active-empty"');
|
||||
expect(html).not.toContain("No active tokens");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -150,7 +152,12 @@ function ApiTokensCard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
|
||||
|
||||
const { data: tokens } = useQuery({
|
||||
const {
|
||||
data: tokens,
|
||||
isError,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.tokens(),
|
||||
queryFn: async () => {
|
||||
return apiGet<ApiTokenInfo[]>("/api/tokens");
|
||||
@@ -215,87 +222,196 @@ function ApiTokensCard() {
|
||||
medication. This card lists and revokes. */}
|
||||
<p className="text-sm">{t("settings.tokenMintMovedDescription")}</p>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
{t("settings.activeTokensTitle")}
|
||||
</p>
|
||||
{/* Desktop table — verbatim layout for md+, hidden on phones. */}
|
||||
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[860px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTablePermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableStatus")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableLastUsed")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("settings.tokenTableActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{activeTokens.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="text-muted-foreground px-3 py-4 text-center text-sm"
|
||||
>
|
||||
{t("settings.noActiveTokens")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{activeTokens.map((tok, index) => {
|
||||
const isExpired =
|
||||
tok.expiresAt && new Date(tok.expiresAt) < new Date();
|
||||
return (
|
||||
<tr
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{tok.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isExpired ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-success/15 text-success text-xs">
|
||||
{t("settings.tokenActive")}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(tok.createdAt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{isError ? (
|
||||
// A read failure must not read as "no active tokens" on this
|
||||
// security surface — the §6 fall-through the token list must avoid.
|
||||
<QueryErrorCard onRetry={() => refetch()} />
|
||||
) : isLoading ? (
|
||||
<div data-testid="settings-api-tokens-loading">
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">
|
||||
{t("settings.activeTokensTitle")}
|
||||
</p>
|
||||
{/* Desktop table — verbatim layout for md+, hidden on phones. */}
|
||||
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[860px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTablePermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableStatus")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableLastUsed")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("settings.tokenTableActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{activeTokens.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="text-muted-foreground px-3 py-4 text-center text-sm"
|
||||
>
|
||||
{t("settings.noActiveTokens")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{activeTokens.map((tok, index) => {
|
||||
const isExpired =
|
||||
tok.expiresAt && new Date(tok.expiresAt) < new Date();
|
||||
return (
|
||||
<tr
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{tok.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{isExpired ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-success/15 text-success text-xs">
|
||||
{t("settings.tokenActive")}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(tok.createdAt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-9 w-9"
|
||||
aria-label={t("settings.tokenRevokeAction")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.tokenRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.tokenRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — each row stacks its columns vertically
|
||||
with explicit labels so the data is readable inside a
|
||||
narrow viewport. The revoke action stays full-width to
|
||||
clear the 44pt tap-target floor. */}
|
||||
{activeTokens.length === 0 ? (
|
||||
<EmptyState
|
||||
className="md:hidden"
|
||||
size="compact"
|
||||
data-testid="settings-api-tokens-active-empty"
|
||||
title={t("settings.noActiveTokens")}
|
||||
/>
|
||||
) : (
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-tokens-mobile-list"
|
||||
>
|
||||
{activeTokens.map((tok) => {
|
||||
const isExpired =
|
||||
tok.expiresAt && new Date(tok.expiresAt) < new Date();
|
||||
return (
|
||||
<li
|
||||
key={tok.id}
|
||||
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm font-medium break-all">
|
||||
{tok.name}
|
||||
</p>
|
||||
{isExpired ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-success/15 text-success text-xs">
|
||||
{t("settings.tokenActive")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{tok.permissions.join(", ")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableCreated")}:
|
||||
</span>{" "}
|
||||
{formatDate(tok.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableLastUsed")}:
|
||||
</span>{" "}
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-9 w-9"
|
||||
aria-label={t("settings.tokenRevokeAction")}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/30 min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("settings.tokenRevoke")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
@@ -320,212 +436,117 @@ function ApiTokensCard() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — each row stacks its columns vertically
|
||||
with explicit labels so the data is readable inside a
|
||||
narrow viewport. The revoke action stays full-width to
|
||||
clear the 44pt tap-target floor. */}
|
||||
{activeTokens.length === 0 ? (
|
||||
<EmptyState
|
||||
className="md:hidden"
|
||||
size="compact"
|
||||
data-testid="settings-api-tokens-active-empty"
|
||||
title={t("settings.noActiveTokens")}
|
||||
/>
|
||||
) : (
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-tokens-mobile-list"
|
||||
>
|
||||
{activeTokens.map((tok) => {
|
||||
const isExpired =
|
||||
tok.expiresAt && new Date(tok.expiresAt) < new Date();
|
||||
return (
|
||||
<li
|
||||
key={tok.id}
|
||||
className="bg-muted/30 border-border space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="min-w-0 flex-1 text-sm font-medium break-all">
|
||||
{tok.name}
|
||||
</p>
|
||||
{isExpired ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{t("settings.tokenExpired")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-success/15 text-success text-xs">
|
||||
{t("settings.tokenActive")}
|
||||
</Badge>
|
||||
)}
|
||||
{revokedTokens.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRevokedTokens((prev) => !prev)}
|
||||
className="text-foreground hover:text-primary flex items-center gap-1 text-sm font-medium transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${showRevokedTokens ? "rotate-180" : ""}`}
|
||||
/>
|
||||
{t("settings.revokedTokensTitle", {
|
||||
count: revokedTokens.length,
|
||||
})}
|
||||
</button>
|
||||
{showRevokedTokens && (
|
||||
<>
|
||||
{/* Desktop table — verbatim layout for md+. */}
|
||||
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[760px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTablePermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableLastUsed")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{revokedTokens.map((tok, index) => (
|
||||
<tr
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{tok.name}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(tok.createdAt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{tok.permissions.join(", ")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableCreated")}:
|
||||
</span>{" "}
|
||||
{formatDate(tok.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableLastUsed")}:
|
||||
</span>{" "}
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/30 min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("settings.tokenRevoke")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.tokenRevoke")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.tokenRevokeDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{revokedTokens.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRevokedTokens((prev) => !prev)}
|
||||
className="text-foreground hover:text-primary flex items-center gap-1 text-sm font-medium transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${showRevokedTokens ? "rotate-180" : ""}`}
|
||||
/>
|
||||
{t("settings.revokedTokensTitle", {
|
||||
count: revokedTokens.length,
|
||||
})}
|
||||
</button>
|
||||
{showRevokedTokens && (
|
||||
<>
|
||||
{/* Desktop table — verbatim layout for md+. */}
|
||||
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[760px] text-sm md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTablePermissions")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.tokenTableLastUsed")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{revokedTokens.map((tok, index) => (
|
||||
<tr
|
||||
{/* Mobile card list — stacked meta layout, no
|
||||
horizontal scroll. Revoked tokens are read-only so
|
||||
no action footer is needed. */}
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-tokens-revoked-mobile-list"
|
||||
>
|
||||
{revokedTokens.map((tok) => (
|
||||
<li
|
||||
key={tok.id}
|
||||
className={index % 2 === 0 ? "bg-muted/20" : ""}
|
||||
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{tok.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
<p className="text-sm font-medium break-all">
|
||||
{tok.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{tok.permissions.join(", ")}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableCreated")}:
|
||||
</span>{" "}
|
||||
{formatDate(tok.createdAt)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableLastUsed")}:
|
||||
</span>{" "}
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</td>
|
||||
</tr>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list — stacked meta layout, no
|
||||
horizontal scroll. Revoked tokens are read-only so
|
||||
no action footer is needed. */}
|
||||
<ul
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-tokens-revoked-mobile-list"
|
||||
>
|
||||
{revokedTokens.map((tok) => (
|
||||
<li
|
||||
key={tok.id}
|
||||
className="bg-muted/30 border-border space-y-1.5 rounded-lg border p-3"
|
||||
>
|
||||
<p className="text-sm font-medium break-all">
|
||||
{tok.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTablePermissions")}:
|
||||
</span>{" "}
|
||||
{tok.permissions.join(", ")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableCreated")}:
|
||||
</span>{" "}
|
||||
{formatDate(tok.createdAt)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.tokenTableLastUsed")}:
|
||||
</span>{" "}
|
||||
{tok.lastUsedAt
|
||||
? formatDateTime(tok.lastUsedAt)
|
||||
: t("settings.tokenNeverUsed")}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
Reference in new issue
Block a user