mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(admin): surface the carrier chip under the auth provider on the login overview
Adds a `carrierShortLabel()` helper in `_shared.tsx` that folds the verbose GeoLite2 `autonomous_system_organization` strings down to the glanceable DACH short labels Marc asked for: "Telekom", "Vodafone", "1&1", "O2". Unknown organisations fall through to the raw string so the chip stays informative. `LoginOverviewSection` now renders the short label as a smaller chip below the existing auth-provider chip, gated on `entry.carrier !== null` so private-IP and offline-miss rows keep the legacy single-chip layout. The CSV export grows a matching `carrier` column between `location` and `provider`. The API selector pulls the new `asn` + `carrier` fields. New translation keys (`admin.carrier`, `admin.carrierUnknown`) are referenced in code; bucket B6 lands the JSON entries.
This commit is contained in:
1 parent
fe18a8810c
commit
25952d7d05
4 files changed
+202
-2
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<AuthProvider, string> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<AuthProvider, string> {
|
||||
* 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,
|
||||
|
||||
@@ -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]}
|
||||
</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
|
||||
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 ? (
|
||||
<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 ?? "—"}
|
||||
|
||||
Reference in new issue
Block a user