mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(admin): move pagination and CSV export out of the table scroll containers on login-overview and app-log-preview
CF-21: the audit-log prev/next controls and summary row used to live inside the same `overflow-x-auto` wrapper as the table. On narrow viewports admins had to scroll the table back to its starting offset before the pagination became visible. Same problem for the app-log preview summary. Both rows now sit in a sibling block under the scroll container, while the table itself keeps panning horizontally on touch. The login-overview CSV export was already outside the wrapper and stays put. The recent B3 carrier-chip layout is unchanged.
This commit is contained in:
1 parent
2bd659f66d
commit
0fb0235d17
4 files changed
+386
-139
No files matched your search
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB5 — `/admin/app-logs` summary line lifted out of the
|
||||
* `overflow-x-auto` table wrapper so admins on narrow viewports can
|
||||
* still see "showing X of Y events" without first scrolling the table
|
||||
* back to its starting offset. The refresh control already lives in
|
||||
* the section header.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/app-logs",
|
||||
}));
|
||||
|
||||
const sampleEvents = [
|
||||
{
|
||||
request_id: "req-1",
|
||||
trace_id: "abcdef1234567890",
|
||||
level: "info" as const,
|
||||
timestamp: "2026-05-10T09:00:00Z",
|
||||
duration_ms: 12,
|
||||
action: { name: "auth.login.success" },
|
||||
kind: "request",
|
||||
http: { method: "POST", path: "/api/auth/login" },
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
events: sampleEvents,
|
||||
meta: { total: 1, bufferMax: 500 },
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
isFetching: false,
|
||||
}),
|
||||
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 { AppLogPreviewSection } from "../app-log-preview-section";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AppLogPreviewSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AppLogPreviewSection — summary lives outside the scroll wrapper", () => {
|
||||
it("renders the summary row with the stable test id", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="app-log-preview-summary"');
|
||||
});
|
||||
|
||||
it("places the summary row outside any `overflow-x-auto` container", () => {
|
||||
const html = render();
|
||||
const summaryIdx = html.indexOf('data-testid="app-log-preview-summary"');
|
||||
expect(summaryIdx).toBeGreaterThan(-1);
|
||||
const before = html.slice(0, summaryIdx);
|
||||
const lastScrollOpen = before.lastIndexOf('class="overflow-x-auto"');
|
||||
expect(lastScrollOpen).toBeGreaterThan(-1);
|
||||
const tail = before.slice(lastScrollOpen);
|
||||
expect(tail).toContain("</div>");
|
||||
});
|
||||
|
||||
it("does not nest the summary copy inside the scroll wrapper", () => {
|
||||
const html = render();
|
||||
const wrapper = html.match(
|
||||
/<div class="overflow-x-auto"[^>]*>[\s\S]*?<\/table><\/div>/,
|
||||
);
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper![0]).not.toContain("app-log-preview-summary");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB5 — `/admin/login-overview` pagination + summary lifted
|
||||
* out of the `overflow-x-auto` table wrapper.
|
||||
*
|
||||
* The audit table can be wider than the viewport on phones, so it
|
||||
* stays in an `overflow-x-auto` container that pans horizontally on
|
||||
* touch. Before this fix the prev/next controls lived *inside* that
|
||||
* same wrapper, meaning admins on narrow viewports had to scroll the
|
||||
* table back to its starting offset before they could reach the
|
||||
* pagination controls. The summary "showing X of Y" line was hidden
|
||||
* the same way. The fix moves both into a sibling block under the
|
||||
* scroll container.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/login-overview",
|
||||
}));
|
||||
|
||||
const sampleEntries = [
|
||||
{
|
||||
id: "ev-1",
|
||||
user: { id: "u1", username: "marc" },
|
||||
action: "auth.login.success",
|
||||
ipAddress: "203.0.113.10",
|
||||
location: "Berlin, DE",
|
||||
carrier: null,
|
||||
createdAt: "2026-05-10T09:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
entries: sampleEntries,
|
||||
meta: { total: 23, limit: 50, offset: 0, page: 1, perPage: 50 },
|
||||
},
|
||||
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",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { LoginOverviewSection } from "../login-overview-section";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<LoginOverviewSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoginOverviewSection — pagination lives outside the scroll wrapper", () => {
|
||||
it("renders the pagination row with the stable test id", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="login-overview-pagination"');
|
||||
});
|
||||
|
||||
it("places the pagination row outside any `overflow-x-auto` container", () => {
|
||||
const html = render();
|
||||
// Capture the pagination block and assert that the rendered
|
||||
// markup leading up to its opening tag closes the scroll
|
||||
// container first. The simplest invariant: there is no
|
||||
// `<div class="overflow-x-auto">` ancestor that is still open
|
||||
// when the pagination div opens. We approximate this by finding
|
||||
// the last `<div class="overflow-x-auto"` before the pagination
|
||||
// div and confirming the inner content closes (matching `</div>`)
|
||||
// before the pagination div opens.
|
||||
const paginationIdx = html.indexOf(
|
||||
'data-testid="login-overview-pagination"',
|
||||
);
|
||||
expect(paginationIdx).toBeGreaterThan(-1);
|
||||
const before = html.slice(0, paginationIdx);
|
||||
const lastScrollOpen = before.lastIndexOf('class="overflow-x-auto"');
|
||||
expect(lastScrollOpen).toBeGreaterThan(-1);
|
||||
// Between the scroll-wrapper opening and the pagination opening
|
||||
// there must be at least one `</div>` closing the wrapper.
|
||||
const tail = before.slice(lastScrollOpen);
|
||||
expect(tail).toContain("</div>");
|
||||
});
|
||||
|
||||
it("does not nest the prev/next buttons inside the scroll wrapper", () => {
|
||||
const html = render();
|
||||
// Pin the entire scroll wrapper and confirm the prev/next button
|
||||
// labels are not within it. We slice the markup between the
|
||||
// wrapper's opening tag and its first balanced closing tag at the
|
||||
// same depth — naively by grabbing up to `</table></div>`, which
|
||||
// matches the structure produced by the current implementation.
|
||||
const wrapper = html.match(
|
||||
/<div class="overflow-x-auto"[^>]*>[\s\S]*?<\/table><\/div>/,
|
||||
);
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper![0]).not.toContain("admin.section.auditLog.next");
|
||||
// The English labels also must not appear inside the wrapper.
|
||||
expect(wrapper![0]).not.toMatch(/>\s*Next\s*</);
|
||||
expect(wrapper![0]).not.toMatch(/>\s*Previous\s*</);
|
||||
});
|
||||
});
|
||||
@@ -204,54 +204,64 @@ export function AppLogPreviewSection() {
|
||||
description={t("admin.section.app-logs.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colLevel")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colTimestamp")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colAction")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-right font-medium">
|
||||
{t("admin.section.app-logs.colDuration")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colTraceId")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{events.map((event, i) => (
|
||||
<tr
|
||||
key={`${event.request_id}-${i}`}
|
||||
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
|
||||
onClick={() => setSelected(event)}
|
||||
>
|
||||
<td className="px-2 py-2">{levelIcon(event.level)}</td>
|
||||
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
|
||||
{formatDateTime(event.timestamp)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs">
|
||||
{event.action?.name ??
|
||||
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
|
||||
event.kind)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
|
||||
{event.duration_ms} ms
|
||||
</td>
|
||||
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
|
||||
{event.trace_id.slice(0, 8)}…
|
||||
</td>
|
||||
// v1.4.27 MB5 — keep the table scroll container, but lift the
|
||||
// "showing X of Y" summary line out into a sibling `<div>` so
|
||||
// the meta info stays reachable on narrow viewports even when
|
||||
// the table is scrolled horizontally. The refresh control was
|
||||
// already in the header above the table wrapper.
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colLevel")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colTimestamp")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colAction")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-right font-medium">
|
||||
{t("admin.section.app-logs.colDuration")}
|
||||
</th>
|
||||
<th className="px-2 py-2 text-left font-medium">
|
||||
{t("admin.section.app-logs.colTraceId")}
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{events.map((event, i) => (
|
||||
<tr
|
||||
key={`${event.request_id}-${i}`}
|
||||
className={`${i % 2 === 0 ? "bg-muted/30" : ""} hover:bg-muted cursor-pointer`}
|
||||
onClick={() => setSelected(event)}
|
||||
>
|
||||
<td className="px-2 py-2">{levelIcon(event.level)}</td>
|
||||
<td className="text-muted-foreground px-2 py-2 text-xs whitespace-nowrap">
|
||||
{formatDateTime(event.timestamp)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs">
|
||||
{event.action?.name ??
|
||||
(`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
|
||||
event.kind)}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-2 py-2 text-right font-mono text-xs">
|
||||
{event.duration_ms} ms
|
||||
</td>
|
||||
<td className="text-muted-foreground px-2 py-2 font-mono text-xs">
|
||||
{event.trace_id.slice(0, 8)}…
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground mt-3 flex items-center justify-between text-xs"
|
||||
data-testid="app-log-preview-summary"
|
||||
>
|
||||
<span>
|
||||
{t("admin.section.app-logs.showing", {
|
||||
count: events.length,
|
||||
@@ -259,7 +269,7 @@ export function AppLogPreviewSection() {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
|
||||
@@ -368,26 +368,34 @@ export function LoginOverviewSection() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<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.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.action")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.provider")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.ip")}
|
||||
</th>
|
||||
{/*
|
||||
// v1.4.27 MB5 — the audit table itself stays in an
|
||||
// `overflow-x-auto` scroll container so wide rows can pan
|
||||
// horizontally on phones, but the pagination + summary line
|
||||
// moved out into a sibling `<div>` below so the prev/next
|
||||
// controls stay reachable without first scrolling the table
|
||||
// back to its starting offset. The CSV-export button was
|
||||
// already in the toolbar row above the table wrapper.
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<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.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.action")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.provider")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.ip")}
|
||||
</th>
|
||||
{/*
|
||||
v1.4.25 W8b — Standort column. Previously rendered
|
||||
`text-right`, which on wide audit tables let the
|
||||
`Berlin, DE` label drift to the table edge and
|
||||
@@ -396,53 +404,53 @@ export function LoginOverviewSection() {
|
||||
consistency) makes Standort a first-class column
|
||||
that admins actually notice.
|
||||
*/}
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" aria-hidden="true" />
|
||||
{t("admin.location")}
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.timestamp")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{entries.map((entry, i) => {
|
||||
const isFailed = entry.action === "auth.login.failed";
|
||||
const provider = providerForAction(entry.action);
|
||||
const ProviderIcon = iconForAuthProvider(provider);
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
{isFailed ? (
|
||||
<XCircle className="text-destructive h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="text-dracula-green h-4 w-4" />
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" aria-hidden="true" />
|
||||
{t("admin.location")}
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.timestamp")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{entries.map((entry, i) => {
|
||||
const isFailed = entry.action === "auth.login.failed";
|
||||
const provider = providerForAction(entry.action);
|
||||
const ProviderIcon = iconForAuthProvider(provider);
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
>
|
||||
{entry.user?.username ?? t("common.unknown")}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProviderIcon
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{AUTH_PROVIDER_LABELS[provider]}
|
||||
</span>
|
||||
{/*
|
||||
<td className="px-3 py-2">
|
||||
{isFailed ? (
|
||||
<XCircle className="text-destructive h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="text-dracula-green h-4 w-4" />
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
|
||||
>
|
||||
{entry.user?.username ?? t("common.unknown")}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProviderIcon
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{AUTH_PROVIDER_LABELS[provider]}
|
||||
</span>
|
||||
{/*
|
||||
v1.4.27 B3 — carrier chip below the auth-provider
|
||||
chip. Only renders when the GeoLite2-ASN lookup
|
||||
found an organisation string for the audit-row's
|
||||
@@ -452,30 +460,34 @@ export function LoginOverviewSection() {
|
||||
"Vodafone", "1&1", "O2"); unknown organisations
|
||||
fall through to the raw GeoLite2 string.
|
||||
*/}
|
||||
{entry.carrier ? (
|
||||
<span
|
||||
className="text-muted-foreground/80 mt-0.5 block text-[10px] leading-tight"
|
||||
data-slot="login-overview-carrier"
|
||||
>
|
||||
{carrierShortLabel(entry.carrier)}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
|
||||
{entry.ipAddress ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{entry.location ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDateTime(entry.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
{entry.carrier ? (
|
||||
<span
|
||||
className="text-muted-foreground/80 mt-0.5 block text-[10px] leading-tight"
|
||||
data-slot="login-overview-carrier"
|
||||
>
|
||||
{carrierShortLabel(entry.carrier)}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
|
||||
{entry.ipAddress ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{entry.location ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDateTime(entry.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 text-xs"
|
||||
data-testid="login-overview-pagination"
|
||||
>
|
||||
<span>
|
||||
{t("admin.showingEntries", {
|
||||
count: entries.length,
|
||||
@@ -507,7 +519,7 @@ export function LoginOverviewSection() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user