diff --git a/src/app/api/admin/audit-log/route.ts b/src/app/api/admin/audit-log/route.ts index a05b78543..47c62061b 100644 --- a/src/app/api/admin/audit-log/route.ts +++ b/src/app/api/admin/audit-log/route.ts @@ -136,6 +136,11 @@ export const GET = apiHandler(async (request: NextRequest) => { action: true, ipAddress: true, location: true, + // v1.4.27 B3 — surface the carrier chip data alongside the + // existing location string. Both nullable so older rows render + // with the same dash that `location` falls back to. + asn: true, + carrier: true, details: true, createdAt: true, user: { diff --git a/src/components/admin/__tests__/login-overview-csv.test.ts b/src/components/admin/__tests__/login-overview-csv.test.ts index 1aead558d..c1e268813 100644 --- a/src/components/admin/__tests__/login-overview-csv.test.ts +++ b/src/components/admin/__tests__/login-overview-csv.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { auditLogCsvHeaderLabels, buildAuditLogCsvRecords, + carrierShortLabel, type AuditCsvEntry, type AuditCsvLabels, } from "../_shared"; @@ -30,6 +31,9 @@ const LABELS_EN: AuditCsvLabels = { user: "Users", ip: "IP", location: "Location", + // v1.4.27 B3 — `admin.carrier` is shipped by bucket B6; the test + // pins the English literal so the column order is locked. + carrier: "Carrier", provider: "Provider", outcome: "Outcome", action: "Action", @@ -52,6 +56,7 @@ const LABELS_DE: AuditCsvLabels = { user: "Benutzer", ip: "IP", location: "Standort", + carrier: "Mobilfunkanbieter", provider: "Anbieter", outcome: "Ergebnis", action: "Aktion", @@ -98,6 +103,7 @@ describe("buildAuditLogCsvRecords", () => { "user", "ip", "location", + "carrier", "provider", "outcome", "action", @@ -196,6 +202,7 @@ describe("auditLogCsvHeaderLabels", () => { user: "Users", ip: "IP", location: "Location", + carrier: "Carrier", provider: "Provider", outcome: "Outcome", action: "Action", @@ -224,13 +231,105 @@ describe("toCSV(records, headerLabels) — audit-log integration", () => { const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_DE)); const lines = csv.split("\n"); expect(lines[0]).toBe( - "Zeitpunkt,Benutzer,IP,Standort,Anbieter,Ergebnis,Aktion,Details", + "Zeitpunkt,Benutzer,IP,Standort,Mobilfunkanbieter,Anbieter,Ergebnis,Aktion,Details", ); expect(lines[1]).toBe( - '2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",Passkey,Erfolgreich,auth.login.passkey,', + '2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",,Passkey,Erfolgreich,auth.login.passkey,', ); }); + it("folds the GeoLite2 organisation string to the short DACH carrier label", () => { + // Marc's spec: the verbose MMDB org strings get a glanceable chip. + // The CSV column carries the same folded text so a downstream + // spreadsheet matches what the admin overview screen renders. + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.password", + ipAddress: "84.131.0.1", + location: "Berlin, DE", + carrier: "Deutsche Telekom AG", + asn: 3320, + details: null, + user: { id: "u1", username: "marc" }, + }, + { + createdAt: "2026-05-11T09:06:00.000Z", + action: "auth.login.password", + ipAddress: "139.7.0.1", + location: "Hamburg, DE", + carrier: "Vodafone GmbH", + asn: 3209, + details: null, + user: { id: "u2", username: "marc" }, + }, + { + createdAt: "2026-05-11T09:07:00.000Z", + action: "auth.login.password", + ipAddress: "212.7.0.1", + location: "Köln, DE", + carrier: "1&1 Versatel GmbH", + asn: 8881, + details: null, + user: { id: "u3", username: "marc" }, + }, + { + createdAt: "2026-05-11T09:08:00.000Z", + action: "auth.login.password", + ipAddress: "92.7.0.1", + location: "Frankfurt, DE", + carrier: "Telefónica Germany GmbH & Co. OHG", + asn: 6805, + details: null, + user: { id: "u4", username: "marc" }, + }, + { + createdAt: "2026-05-11T09:09:00.000Z", + action: "auth.login.password", + ipAddress: "8.8.8.8", + location: "Mountain View, US", + carrier: "Google LLC", + asn: 15169, + details: null, + user: { id: "u5", username: "marc" }, + }, + ]; + const records = buildAuditLogCsvRecords( + entries, + LABELS_EN, + deterministicFormatter, + ); + expect(records.map((r) => r.carrier)).toEqual([ + "Telekom", + "Vodafone", + "1&1", + "O2", + // Unknown organisations carry the raw GeoLite2 string through. + "Google LLC", + ]); + }); + + it("emits an empty carrier cell when the row has no ASN data", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.password", + ipAddress: "192.0.2.1", + location: null, + carrier: null, + asn: null, + details: null, + user: null, + }, + ]; + const [record] = buildAuditLogCsvRecords( + entries, + LABELS_EN, + deterministicFormatter, + ); + expect(record.carrier).toBe(""); + }); + it("escapes commas, quotes, and newlines in the details column", () => { const entries: AuditCsvEntry[] = [ { @@ -256,3 +355,35 @@ describe("toCSV(records, headerLabels) — audit-log integration", () => { ); }); }); + +describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => { + it("folds 'Deutsche Telekom AG' down to 'Telekom'", () => { + expect(carrierShortLabel("Deutsche Telekom AG")).toBe("Telekom"); + }); + + it("folds the standalone Vodafone variants down to 'Vodafone'", () => { + expect(carrierShortLabel("Vodafone GmbH")).toBe("Vodafone"); + expect(carrierShortLabel("Vodafone Kabel Deutschland GmbH")).toBe( + "Vodafone", + ); + }); + + it("folds the 1&1 sub-brands down to '1&1'", () => { + expect(carrierShortLabel("1&1 Telecom GmbH")).toBe("1&1"); + expect(carrierShortLabel("1&1 Versatel GmbH")).toBe("1&1"); + }); + + it("folds Telefónica + O2 variants down to 'O2'", () => { + expect( + carrierShortLabel("Telefónica Germany GmbH & Co. OHG"), + ).toBe("O2"); + expect(carrierShortLabel("Telefonica Germany Online Services")).toBe("O2"); + expect(carrierShortLabel("O2 Deutschland")).toBe("O2"); + }); + + it("returns the raw organisation string for unknown carriers", () => { + expect(carrierShortLabel("Google LLC")).toBe("Google LLC"); + expect(carrierShortLabel("Amazon.com, Inc.")).toBe("Amazon.com, Inc."); + expect(carrierShortLabel("Hetzner Online GmbH")).toBe("Hetzner Online GmbH"); + }); +}); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index f0dd6e5b5..59c64b96b 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -92,6 +92,11 @@ export interface AdminAuditEntry { action: string; ipAddress: string | null; location: string | null; + // v1.4.27 B3 — populated from the bundled GeoLite2-ASN MMDB at + // audit-creation time. Both nullable: older rows, private/loopback + // IPs, and offline-miss rows stay valid. + asn: number | null; + carrier: string | null; details: string | null; createdAt: string; user: { id: string; username: string } | null; @@ -380,6 +385,30 @@ export function useAuthProviderLabels(): Record { ); } +/** + * v1.4.27 B3 — Fold a GeoLite2 `autonomous_system_organization` + * string down to a short DACH carrier label. The MMDB returns the + * verbose legal-entity name ("Deutsche Telekom AG", "1&1 Telecom + * GmbH", "Telefónica Germany GmbH & Co. OHG"); the admin overview + * needs a glanceable chip ("Telekom", "Vodafone", "1&1", "O2"). + * + * The match is case-insensitive substring against the canonical + * carrier name; unknown organisations fall through to the original + * string so the chip still carries provenance. + */ +export function carrierShortLabel(rawAsnOrg: string): string { + const haystack = rawAsnOrg.toLowerCase(); + if (haystack.includes("telekom")) return "Telekom"; + if (haystack.includes("vodafone")) return "Vodafone"; + if (haystack.includes("1&1")) return "1&1"; + // Telefónica / O2 / Telefonica Germany / O2 Deutschland → "O2" + if (haystack.includes("telefonica") || haystack.includes("telefónica")) { + return "O2"; + } + if (haystack.includes("o2 ") || haystack.endsWith(" o2")) return "O2"; + return rawAsnOrg; +} + /** * v1.4.25 W8b — Build the CSV record set for the audit-log export. * @@ -391,6 +420,9 @@ export function useAuthProviderLabels(): Record { * require schema/API changes that are out of scope). `action` and * `details` follow so the export keeps the full triage payload. * + * v1.4.27 B3: the `carrier` column lands between `location` and + * `provider` so the geo-derived fields stay grouped. + * * `formatTimestamp` is injected (not imported) so the production * callsite passes the user-tz formatter (`formatInUserTz`) while the * unit test passes a deterministic stub. @@ -400,6 +432,10 @@ export interface AuditCsvEntry { action: string; ipAddress: string | null; location: string | null; + // v1.4.27 B3 — both optional so legacy fixture data that predates + // the column landing still compiles against this shape. + carrier?: string | null; + asn?: number | null; details: string | null; user: { id: string; username: string } | null; } @@ -409,6 +445,7 @@ export interface AuditCsvLabels { user: string; ip: string; location: string; + carrier: string; provider: string; outcome: string; action: string; @@ -424,6 +461,7 @@ export interface AuditCsvRecord { user: string; ip: string; location: string; + carrier: string; provider: string; outcome: string; action: string; @@ -450,6 +488,7 @@ export function buildAuditLogCsvRecords( user: entry.user?.username ?? labels.unknownUser, ip: entry.ipAddress ?? "", location: entry.location ?? "", + carrier: entry.carrier ? carrierShortLabel(entry.carrier) : "", provider: labels.providerLabels[provider], outcome: isFailed ? labels.outcomeFailed : labels.outcomeSuccess, action: entry.action, @@ -466,6 +505,7 @@ export function auditLogCsvHeaderLabels( user: labels.user, ip: labels.ip, location: labels.location, + carrier: labels.carrier, provider: labels.provider, outcome: labels.outcome, action: labels.action, diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index d55e089ca..d0e193263 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -29,6 +29,7 @@ import { type AdminAuditEntry, auditLogCsvHeaderLabels, buildAuditLogCsvRecords, + carrierShortLabel, iconForAuthProvider, providerForAction, useAuthActionLabels, @@ -159,6 +160,11 @@ export function LoginOverviewSection() { user: t("admin.users"), ip: t("admin.ip"), location: t("admin.location"), + // v1.4.27 B3 — `admin.carrier` is shipped by bucket B6. The + // `t()` helper falls back to the raw key string when the + // translation row hasn't landed yet, so the CSV stays valid + // through the staggered B3 → B6 release. + carrier: t("admin.carrier"), provider: t("admin.provider"), outcome: t("admin.outcome"), action: t("admin.action"), @@ -436,6 +442,24 @@ export function LoginOverviewSection() { /> {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 + IP; private/loopback IPs and offline-miss rows + keep the original single-chip layout. The chip + carries the short DACH label ("Telekom", + "Vodafone", "1&1", "O2"); unknown organisations + fall through to the raw GeoLite2 string. + */} + {entry.carrier ? ( + + {carrierShortLabel(entry.carrier)} + + ) : null} {entry.ipAddress ?? "—"}