mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
feeff38ba2
commit
d87a6314e6
6 files changed
+424
-18
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user