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*\s*Previous\s* ) : ( -
- - - - - - - - - - - - {events.map((event, i) => ( - setSelected(event)} - > - - - - - + // 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")} -
{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.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() { })}
-
+ )} ) : ( -
- - - - - - - - - {/* + // 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. */} - - - - - - {entries.map((entry, i) => { - const isFailed = entry.action === "auth.login.failed"; - const provider = providerForAction(entry.action); - const ProviderIcon = iconForAuthProvider(provider); - return ( - - - + + + + {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")} - - - + + + - - - - - ); - })} - -
+ {t("admin.status")} + + {t("admin.users")} + + {t("admin.action")} + + {t("admin.provider")} + + {t("admin.ip")} + - - - - {t("admin.timestamp")} -
- {isFailed ? ( - - ) : ( - - )} - + + + + + {t("admin.timestamp")} +
- {AUTH_ACTION_LABELS[entry.action] ?? entry.action} - - - - {/* + + {isFailed ? ( + + ) : ( + + )} + + {entry.user?.username ?? t("common.unknown")} + + {AUTH_ACTION_LABELS[entry.action] ?? entry.action} + + + + {/* 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() {
-
+ )}