diff --git a/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx b/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx
new file mode 100644
index 000000000..9f0634861
--- /dev/null
+++ b/src/components/admin/__tests__/app-log-preview-summary-position.test.tsx
@@ -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(
+
+
+ ,
+ );
+}
+
+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("");
+ });
+
+ it("does not nest the summary copy inside the scroll wrapper", () => {
+ const html = render();
+ const wrapper = html.match(
+ /
]*>[\s\S]*?<\/table><\/div>/,
+ );
+ expect(wrapper).not.toBeNull();
+ expect(wrapper![0]).not.toContain("app-log-preview-summary");
+ });
+});
diff --git a/src/components/admin/__tests__/login-overview-pagination-position.test.tsx b/src/components/admin/__tests__/login-overview-pagination-position.test.tsx
new file mode 100644
index 000000000..2950e2098
--- /dev/null
+++ b/src/components/admin/__tests__/login-overview-pagination-position.test.tsx
@@ -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(
+
+
+ ,
+ );
+}
+
+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
+ // `
` ancestor that is still open
+ // when the pagination div opens. We approximate this by finding
+ // the last `
`)
+ // 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 `
` closing the wrapper.
+ const tail = before.slice(lastScrollOpen);
+ expect(tail).toContain("
");
+ });
+
+ 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 `
`, which
+ // matches the structure produced by the current implementation.
+ const wrapper = html.match(
+ /]*>[\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*);
+ });
+});
diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx
index 9d6d9fd51..3bdb5da33 100644
--- a/src/components/admin/app-log-preview-section.tsx
+++ b/src/components/admin/app-log-preview-section.tsx
@@ -204,54 +204,64 @@ export function AppLogPreviewSection() {
description={t("admin.section.app-logs.emptyDescription")}
/>
) : (
-
-
-
-
- |
- {t("admin.section.app-logs.colLevel")}
- |
-
- {t("admin.section.app-logs.colTimestamp")}
- |
-
- {t("admin.section.app-logs.colAction")}
- |
-
- {t("admin.section.app-logs.colDuration")}
- |
-
- {t("admin.section.app-logs.colTraceId")}
- |
-
-
-
- {events.map((event, i) => (
- setSelected(event)}
- >
- | {levelIcon(event.level)} |
-
- {formatDateTime(event.timestamp)}
- |
-
- {event.action?.name ??
- (`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
- event.kind)}
- |
-
- {event.duration_ms} ms
- |
-
- {event.trace_id.slice(0, 8)}…
- |
+ // v1.4.27 MB5 — keep the table scroll container, but lift the
+ // "showing X of Y" summary line out into a sibling `` 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.
+ <>
+
+
+
+
+ |
+ {t("admin.section.app-logs.colLevel")}
+ |
+
+ {t("admin.section.app-logs.colTimestamp")}
+ |
+
+ {t("admin.section.app-logs.colAction")}
+ |
+
+ {t("admin.section.app-logs.colDuration")}
+ |
+
+ {t("admin.section.app-logs.colTraceId")}
+ |
- ))}
-
-
-
+
+
+ {events.map((event, i) => (
+ setSelected(event)}
+ >
+ | {levelIcon(event.level)} |
+
+ {formatDateTime(event.timestamp)}
+ |
+
+ {event.action?.name ??
+ (`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() ||
+ event.kind)}
+ |
+
+ {event.duration_ms} ms
+ |
+
+ {event.trace_id.slice(0, 8)}…
+ |
+
+ ))}
+
+
+
+
{t("admin.section.app-logs.showing", {
count: events.length,
@@ -259,7 +269,7 @@ export function AppLogPreviewSection() {
})}
-
+ >
)}
) : (
-
-
-
-
- |
- {t("admin.status")}
- |
-
- {t("admin.users")}
- |
-
- {t("admin.action")}
- |
-
- {t("admin.provider")}
- |
-
- {t("admin.ip")}
- |
- {/*
+ // 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 `` 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.
+ <>
+
+
+
+
+ |
+ {t("admin.status")}
+ |
+
+ {t("admin.users")}
+ |
+
+ {t("admin.action")}
+ |
+
+ {t("admin.provider")}
+ |
+
+ {t("admin.ip")}
+ |
+ {/*
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.
*/}
-
-
-
- {t("admin.location")}
-
- |
-
- {t("admin.timestamp")}
- |
-
-
-
- {entries.map((entry, i) => {
- const isFailed = entry.action === "auth.login.failed";
- const provider = providerForAction(entry.action);
- const ProviderIcon = iconForAuthProvider(provider);
- return (
-
- |
- {isFailed ? (
-
- ) : (
-
- )}
- |
-
+
+
+ {t("admin.location")}
+
+
+ |
+ {t("admin.timestamp")}
+ |
+
+
+
+ {entries.map((entry, i) => {
+ const isFailed = entry.action === "auth.login.failed";
+ const provider = providerForAction(entry.action);
+ const ProviderIcon = iconForAuthProvider(provider);
+ return (
+
- {entry.user?.username ?? t("common.unknown")}
-
- |
- {AUTH_ACTION_LABELS[entry.action] ?? entry.action}
- |
-
-
-
- {AUTH_PROVIDER_LABELS[provider]}
-
- {/*
+ |
+ {isFailed ? (
+
+ ) : (
+
+ )}
+ |
+
+ {entry.user?.username ?? t("common.unknown")}
+ |
+
+ {AUTH_ACTION_LABELS[entry.action] ?? entry.action}
+ |
+
+
+
+ {AUTH_PROVIDER_LABELS[provider]}
+
+ {/*
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 ? (
-
- {carrierShortLabel(entry.carrier)}
-
- ) : null}
- |
-
- {entry.ipAddress ?? "—"}
- |
-
- {entry.location ?? "—"}
- |
-
- {formatDateTime(entry.createdAt)}
- |
-
- );
- })}
-
-
-
+ {entry.carrier ? (
+
+ {carrierShortLabel(entry.carrier)}
+
+ ) : null}
+
+
+ {entry.ipAddress ?? "—"}
+ |
+
+ {entry.location ?? "—"}
+ |
+
+ {formatDateTime(entry.createdAt)}
+ |
+
+ );
+ })}
+
+
+
+
{t("admin.showingEntries", {
count: entries.length,
@@ -507,7 +519,7 @@ export function LoginOverviewSection() {
-
+ >
)}