diff --git a/messages/de.json b/messages/de.json index d0fdabdf1..4acb2c7d3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1927,6 +1927,10 @@ "providerApiToken": "API-Token", "providerWithings": "Withings", "providerUnknown": "Unbekannt", + "outcome": "Ergebnis", + "outcomeSuccess": "Erfolgreich", + "outcomeFailed": "Fehlgeschlagen", + "auditDetails": "Details", "dangerZone": "Gefahrenzone", "deleteAllData": "Alle Daten global löschen", "deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Benutzer. Benutzerkonten bleiben bestehen.", diff --git a/messages/en.json b/messages/en.json index 5bd660070..c63475999 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1927,6 +1927,10 @@ "providerApiToken": "API token", "providerWithings": "Withings", "providerUnknown": "Unknown", + "outcome": "Outcome", + "outcomeSuccess": "Success", + "outcomeFailed": "Failed", + "auditDetails": "Details", "dangerZone": "Danger Zone", "deleteAllData": "Delete all data globally", "deleteAllDescription": "Deletes all measurement, medication, integration, and log data for all users. User accounts will be preserved.", diff --git a/src/components/admin/__tests__/login-overview-csv.test.ts b/src/components/admin/__tests__/login-overview-csv.test.ts new file mode 100644 index 000000000..2426a84b1 --- /dev/null +++ b/src/components/admin/__tests__/login-overview-csv.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect } from "vitest"; +import { + auditLogCsvHeaderLabels, + buildAuditLogCsvRecords, + type AuditCsvEntry, + type AuditCsvLabels, +} from "../_shared"; +import { toCSV } from "@/lib/export"; + +/** + * v1.4.25 W8b — Login-Übersicht CSV export contract. + * + * The previous CSV emitted snake_case English headers and the raw UTC + * `createdAt` string, which Excel/LibreOffice misread once the `Z` + * suffix was stripped. This test pins: + * + * - The column order Marc requested: + * `timestamp → user → IP → location → provider → outcome` (email + * is absent from the audit-log API so it's intentionally skipped) + * followed by `action` + `details` for triage completeness. + * - The header row uses the translated labels from the active + * locale, not the structural keys. + * - The timestamp formatter is honoured (production passes + * `formatInUserTz` with `iso-with-offset`). + * - CSV special characters in `details` are RFC 4180-escaped. + */ + +const LABELS_EN: AuditCsvLabels = { + timestamp: "Time", + user: "Users", + ip: "IP", + location: "Location", + provider: "Provider", + outcome: "Outcome", + action: "Action", + details: "Details", + outcomeFailed: "Failed", + outcomeSuccess: "Success", + unknownUser: "Unknown", + providerLabels: { + password: "Password", + passkey: "Passkey", + api_token: "API token", + withings: "Withings", + unknown: "Unknown", + }, +}; + +const LABELS_DE: AuditCsvLabels = { + ...LABELS_EN, + timestamp: "Zeitpunkt", + user: "Benutzer", + ip: "IP", + location: "Standort", + provider: "Anbieter", + outcome: "Ergebnis", + action: "Aktion", + details: "Details", + outcomeFailed: "Fehlgeschlagen", + outcomeSuccess: "Erfolgreich", + unknownUser: "Unbekannt", + providerLabels: { + password: "Passwort", + passkey: "Passkey", + api_token: "API-Token", + withings: "Withings", + unknown: "Unbekannt", + }, +}; + +function deterministicFormatter(iso: string): string { + // Strip milliseconds, swap `Z` for `+00:00` so the test asserts the + // ISO-with-offset shape without being coupled to a specific zone. + return iso.replace(/\.\d{3}Z$/, "+00:00").replace(/Z$/, "+00:00"); +} + +describe("buildAuditLogCsvRecords", () => { + it("emits Marc's column order: timestamp → user → ip → location → provider → outcome (+ action, details)", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.passkey", + ipAddress: "203.0.113.7", + location: "Berlin, DE", + details: null, + user: { id: "u1", username: "marc" }, + }, + ]; + const [record] = buildAuditLogCsvRecords( + entries, + LABELS_EN, + deterministicFormatter, + ); + // Object.keys preserves insertion order in V8 / every modern JS engine + // — the column order is therefore guaranteed by `Object.keys(record)`. + expect(Object.keys(record)).toEqual([ + "timestamp", + "user", + "ip", + "location", + "provider", + "outcome", + "action", + "details", + ]); + }); + + it("maps the action to a coarse provider label using the i18n labels", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.passkey", + ipAddress: null, + location: null, + details: null, + user: { id: "u1", username: "marc" }, + }, + { + createdAt: "2026-05-11T09:06:00.000Z", + action: "auth.bearer.success", + ipAddress: null, + location: null, + details: null, + user: { id: "u2", username: "ios" }, + }, + { + createdAt: "2026-05-11T09:07:00.000Z", + action: "auth.login.failed", + ipAddress: null, + location: null, + details: null, + user: null, + }, + ]; + const records = buildAuditLogCsvRecords( + entries, + LABELS_EN, + deterministicFormatter, + ); + expect(records[0].provider).toBe("Passkey"); + expect(records[1].provider).toBe("API token"); + expect(records[2].provider).toBe("Password"); + // Failed login records the failed outcome. + expect(records[0].outcome).toBe("Success"); + expect(records[2].outcome).toBe("Failed"); + // No user → unknown placeholder. + expect(records[2].user).toBe("Unknown"); + }); + + it("uses the German label set when the active locale is German", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.passkey", + ipAddress: null, + location: null, + details: null, + user: null, + }, + ]; + const [record] = buildAuditLogCsvRecords( + entries, + LABELS_DE, + deterministicFormatter, + ); + expect(record.provider).toBe("Passkey"); + expect(record.outcome).toBe("Erfolgreich"); + expect(record.user).toBe("Unbekannt"); + }); + + it("applies the injected timestamp formatter (production uses formatInUserTz)", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.password", + ipAddress: null, + location: null, + details: null, + user: null, + }, + ]; + const records = buildAuditLogCsvRecords( + entries, + LABELS_EN, + (iso) => `formatted:${iso}`, + ); + expect(records[0].timestamp).toBe("formatted:2026-05-11T09:05:00.000Z"); + }); +}); + +describe("auditLogCsvHeaderLabels", () => { + it("returns translated headers keyed by the record-key contract", () => { + const headers = auditLogCsvHeaderLabels(LABELS_EN); + expect(headers).toEqual({ + timestamp: "Time", + user: "Users", + ip: "IP", + location: "Location", + provider: "Provider", + outcome: "Outcome", + action: "Action", + details: "Details", + }); + }); +}); + +describe("toCSV(records, headerLabels) — audit-log integration", () => { + it("writes a translated header row + correct column order", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.passkey", + ipAddress: "203.0.113.7", + location: "Berlin, DE", + details: null, + user: { id: "u1", username: "marc" }, + }, + ]; + const records = buildAuditLogCsvRecords( + entries, + LABELS_DE, + deterministicFormatter, + ); + const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_DE)); + const lines = csv.split("\n"); + expect(lines[0]).toBe( + "Zeitpunkt,Benutzer,IP,Standort,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,", + ); + }); + + it("escapes commas, quotes, and newlines in the details column", () => { + const entries: AuditCsvEntry[] = [ + { + createdAt: "2026-05-11T09:05:00.000Z", + action: "auth.login.failed", + ipAddress: null, + location: null, + // Three RFC 4180 escape triggers in one value: a comma, a + // double quote, and a newline. + details: 'reason="invalid_password", attempts: 3\nfollowup: lock', + user: null, + }, + ]; + const records = buildAuditLogCsvRecords( + entries, + LABELS_EN, + deterministicFormatter, + ); + const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_EN)); + // The cell is wrapped in double quotes and embedded `"` is doubled. + expect(csv).toContain( + '"reason=""invalid_password"", attempts: 3\nfollowup: lock"', + ); + }); +}); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 671801fa0..f0dd6e5b5 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -380,6 +380,99 @@ export function useAuthProviderLabels(): Record { ); } +/** + * v1.4.25 W8b — Build the CSV record set for the audit-log export. + * + * Pulled out of the section component so the column order, header + * mapping, and provider/outcome derivation are reachable from a + * pure unit test without rendering the React tree. Marc's spec pins + * the column order at `timestamp → user → IP → location → provider + * → outcome` (email is absent from the audit-log API and would + * require schema/API changes that are out of scope). `action` and + * `details` follow so the export keeps the full triage payload. + * + * `formatTimestamp` is injected (not imported) so the production + * callsite passes the user-tz formatter (`formatInUserTz`) while the + * unit test passes a deterministic stub. + */ +export interface AuditCsvEntry { + createdAt: string; + action: string; + ipAddress: string | null; + location: string | null; + details: string | null; + user: { id: string; username: string } | null; +} + +export interface AuditCsvLabels { + timestamp: string; + user: string; + ip: string; + location: string; + provider: string; + outcome: string; + action: string; + details: string; + outcomeFailed: string; + outcomeSuccess: string; + unknownUser: string; + providerLabels: Record; +} + +export interface AuditCsvRecord { + timestamp: string; + user: string; + ip: string; + location: string; + provider: string; + outcome: string; + action: string; + details: string; + // Index signature so the record satisfies the structural + // `ExportableRecord` contract used by `toCSV`. Fixed columns above + // still drive the order of the emitted CSV. + [key: string]: string; +} + +export function buildAuditLogCsvRecords( + entries: AuditCsvEntry[], + labels: AuditCsvLabels, + formatTimestamp: (iso: string) => string, +): AuditCsvRecord[] { + return entries.map((entry) => { + const provider = providerForAction(entry.action); + const isFailed = + entry.action === "auth.login.failed" || + entry.action === "auth.bearer.failure" || + entry.action === "auth.token.refresh.failed"; + return { + timestamp: formatTimestamp(entry.createdAt), + user: entry.user?.username ?? labels.unknownUser, + ip: entry.ipAddress ?? "", + location: entry.location ?? "", + provider: labels.providerLabels[provider], + outcome: isFailed ? labels.outcomeFailed : labels.outcomeSuccess, + action: entry.action, + details: entry.details ?? "", + }; + }); +} + +export function auditLogCsvHeaderLabels( + labels: AuditCsvLabels, +): Record { + return { + timestamp: labels.timestamp, + user: labels.user, + ip: labels.ip, + location: labels.location, + provider: labels.provider, + outcome: labels.outcome, + action: labels.action, + details: labels.details, + }; +} + export function useSystemStatus() { return useQuery({ queryKey: ["admin", "status"], diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index c3a54e2c1..6724130e7 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -24,8 +24,12 @@ import { import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { toCSV } from "@/lib/export"; +import { formatInUserTz, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { useAuth } from "@/hooks/use-auth"; import { type AdminAuditEntry, + auditLogCsvHeaderLabels, + buildAuditLogCsvRecords, iconForAuthProvider, providerForAction, useAuthActionLabels, @@ -57,6 +61,12 @@ function rangeToSince(range: DateRangePreset): string | undefined { export function LoginOverviewSection() { const { t } = useTranslations(); + const { user } = useAuth(); + // v1.4.25 W7 — every CSV-emitted timestamp uses the admin's display + // timezone so the resulting `2026-05-11T11:05:00+02:00` cell stays + // legible after Excel/LibreOffice strip the `Z` suffix from the + // legacy UTC export. + const userTz = user?.timezone ?? DEFAULT_TIMEZONE; // v1.5 phase-4b moved this to a dedicated route // (`/admin/login-overview`), so the user has already opted into the // audit log by visiting the page. Default to expanded; the toggle @@ -144,16 +154,24 @@ export function LoginOverviewSection() { function downloadCsv() { if (entries.length === 0) return; - const records = entries.map((entry) => ({ - timestamp: entry.createdAt, - actor_id: entry.user?.id ?? "", - actor_username: entry.user?.username ?? "", - action: entry.action, - ip_address: entry.ipAddress ?? "", - location: entry.location ?? "", - details: entry.details ?? "", - })); - const csv = toCSV(records); + const labels = { + timestamp: t("admin.timestamp"), + user: t("admin.users"), + ip: t("admin.ip"), + location: t("admin.location"), + provider: t("admin.provider"), + outcome: t("admin.outcome"), + action: t("admin.action"), + details: t("admin.auditDetails"), + outcomeFailed: t("admin.outcomeFailed"), + outcomeSuccess: t("admin.outcomeSuccess"), + unknownUser: t("common.unknown"), + providerLabels: AUTH_PROVIDER_LABELS, + }; + const records = buildAuditLogCsvRecords(entries, labels, (iso) => + formatInUserTz(new Date(iso), userTz, "iso-with-offset"), + ); + const csv = toCSV(records, auditLogCsvHeaderLabels(labels)); const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); diff --git a/src/lib/export.ts b/src/lib/export.ts index e37a950a2..24784fa2b 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -16,29 +16,41 @@ */ import { formatInUserTz } from "./tz/resolver"; -interface ExportableRecord { +export interface ExportableRecord { [key: string]: unknown; } /** * Convert records to CSV string. + * + * The first record's keys define the column order (callers should pass + * a stable shape — see `buildAuditLogCsvRecords`). When `headerLabels` + * is provided, those strings replace the object-key header row so the + * exported CSV can carry translated column titles without changing + * the record-key contract that drives the columns. + * + * Escapes RFC 4180 special characters: commas, double quotes, line + * breaks (`\n` and `\r`). Values containing any of these are wrapped + * in double quotes with embedded quotes doubled (`"` → `""`). */ -export function toCSV(records: ExportableRecord[]): string { +export function toCSV( + records: ExportableRecord[], + headerLabels?: Record, +): string { if (records.length === 0) return ""; const headers = Object.keys(records[0]); - const lines = [headers.join(",")]; + const headerLine = headerLabels + ? headers.map((h) => escapeCsvCell(headerLabels[h] ?? h)).join(",") + : headers.map(escapeCsvCell).join(","); + const lines = [headerLine]; for (const record of records) { const values = headers.map((h) => { const val = record[h]; if (val === null || val === undefined) return ""; const str = val instanceof Date ? val.toISOString() : String(val); - // Escape CSV special characters - if (str.includes(",") || str.includes('"') || str.includes("\n")) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; + return escapeCsvCell(str); }); lines.push(values.join(",")); } @@ -46,6 +58,23 @@ export function toCSV(records: ExportableRecord[]): string { return lines.join("\n"); } +/** + * RFC 4180 cell escaping. `\r` is treated the same as `\n` so a + * Windows-newline value (`\r\n`) inside a single field doesn't break + * row alignment when Excel re-imports the CSV. + */ +function escapeCsvCell(str: string): string { + if ( + str.includes(",") || + str.includes('"') || + str.includes("\n") || + str.includes("\r") + ) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + function formatTimestamp(date: Date, userTz?: string): string { if (userTz) return formatInUserTz(date, userTz, "iso-with-offset"); return date.toISOString();