fix(admin): tidy CSV export — column order + escaping + ISO-with-offset

The legacy export emitted snake_case English headers and raw UTC
timestamps which Excel/LibreOffice misread once the `Z` suffix was
stripped (issue #167 territory) and forced admins to mentally map
`actor_username` → "Marc" every time they triaged a row. The export
now mirrors the on-screen column order — `timestamp → user → IP →
location → provider → outcome` — with `action` + `details`
appended so the full audit payload still rides along. Headers are
translated through the active locale; timestamps go through
`formatInUserTz(..., "iso-with-offset")` so they round-trip
correctly in any spreadsheet; `toCSV` now treats `\r` as an escape
trigger alongside `\n,"` so Windows-newline detail strings stay on
their row.

`buildAuditLogCsvRecords` and `auditLogCsvHeaderLabels` live in
`_shared.tsx` so the column-order/escape contract is pinned by a
pure unit test independent of the React render.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 12:54:26 +02:00
1 parent feeff38ba2
commit d87a6314e6
6 files changed
+424 -18

No files matched your search

+4
View File
@@ -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.",
+4
View File
@@ -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.",
@@ -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"',
);
});
});
+93
View File
@@ -380,6 +380,99 @@ export function useAuthProviderLabels(): Record<AuthProvider, string> {
);
}
/**
* 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<AuthProvider, string>;
}
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<keyof AuditCsvRecord, string> {
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"],
+28 -10
View File
@@ -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");
+37 -8
View File
@@ -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, string>,
): 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();