mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites). See CHANGELOG.md for the full per-section delta.
This commit is contained in:
1 parent
de0689e419
commit
17b8d8d2c2
528 files changed
+104033
-6524
No files matched your search
@@ -107,4 +107,34 @@ describe("CoachFeedbackSection", () => {
|
||||
expect(html).toContain("Failed to load Coach feedback summary");
|
||||
expect(html).toContain('role="alert"');
|
||||
});
|
||||
|
||||
// v1.4.25 W8 regression — Marc reported a layout shift on
|
||||
// /admin/coach-feedback. Root cause was the title only rendering in the
|
||||
// empty + data branches, so the section grew taller (loading → data)
|
||||
// and snapped down. Lock the heading + outer-card structure across
|
||||
// every render state so the section maintains a constant top-of-card
|
||||
// baseline.
|
||||
it("keeps the section heading rendered across loading / error / empty states", () => {
|
||||
for (const queryState of [
|
||||
{ isLoading: true, isError: false, data: null },
|
||||
{ isLoading: false, isError: true, data: null },
|
||||
{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: {
|
||||
generatedAt: "2026-05-10T04:00:00Z",
|
||||
windowDays: 30,
|
||||
coachBuckets: [],
|
||||
},
|
||||
},
|
||||
]) {
|
||||
useQueryMock.mockReturnValue(queryState);
|
||||
const html = render();
|
||||
// Sparkles icon + h2 title rendered in every state ⇒ constant
|
||||
// header height ⇒ no layout shift when the query resolves.
|
||||
expect(html).toContain("Coach Feedback");
|
||||
expect(html).toContain("bg-card");
|
||||
expect(html).toContain("rounded-xl");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.25 W8b — `/admin/login-overview` no longer renders a Collapse
|
||||
* / Expand toggle. The toggle was a leftover from the v1.4 shared
|
||||
* admin page where 13 sections lived on one route; collapsing the
|
||||
* only card on the dedicated login-overview route hid the entire
|
||||
* surface. Visiting the page is itself the opt-in.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/login-overview",
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
entries: [],
|
||||
meta: { total: 0, limit: 50, offset: 0, page: 1, perPage: 50 },
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "ADMIN",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { LoginOverviewSection } from "../login-overview-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<LoginOverviewSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoginOverviewSection — no collapse button", () => {
|
||||
it("does not render a Collapse / Expand button (English)", () => {
|
||||
const html = render("en");
|
||||
expect(html).not.toMatch(/\bCollapse\b/);
|
||||
expect(html).not.toMatch(/\bExpand\b/);
|
||||
});
|
||||
|
||||
it("does not render an Einklappen / Ausklappen button (German)", () => {
|
||||
const html = render("de");
|
||||
expect(html).not.toMatch(/\bEinklappen\b/);
|
||||
expect(html).not.toMatch(/\bAusklappen\b/);
|
||||
});
|
||||
|
||||
it("never renders an `aria-expanded` button on the section header", () => {
|
||||
const html = render();
|
||||
// The toggle previously surfaced as <button aria-expanded={…}>
|
||||
// immediately after the ScrollText icon + title pair. Radix
|
||||
// Select triggers below carry `aria-expanded` legitimately, so
|
||||
// we scope the assertion to the first 800 chars of the markup
|
||||
// (the heading slice) instead of the entire SSR output.
|
||||
const heading = html.slice(0, 800);
|
||||
expect(heading).not.toMatch(/aria-expanded=/);
|
||||
});
|
||||
|
||||
it("paints the section body unconditionally (no `expanded` gate)", () => {
|
||||
// The filter pills used to live inside an `{expanded && (…)}`
|
||||
// wrapper. With the toggle gone the pills must always be in the
|
||||
// SSR output.
|
||||
const html = render("en");
|
||||
expect(html).toContain("All auth events");
|
||||
expect(html).toContain("Failed only");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
|
||||
import { iconForAuthProvider, providerForAction } from "../_shared";
|
||||
|
||||
/**
|
||||
* v1.4.25 W8b — Provider column for the admin Login-Übersicht.
|
||||
*
|
||||
* The audit-log entries don't carry a dedicated "provider" field — the
|
||||
* action name itself is the source of truth. These tests pin the
|
||||
* coarse mapping that drives the Provider column (and any later
|
||||
* surface that reuses `useAuthProviderLabels`). New `auth.*` actions
|
||||
* that land in the audit log must be wired into `providerForAction`
|
||||
* with a corresponding regression case here.
|
||||
*/
|
||||
|
||||
describe("providerForAction", () => {
|
||||
it("classifies passkey actions as `passkey`", () => {
|
||||
expect(providerForAction("auth.login.passkey")).toBe("passkey");
|
||||
expect(providerForAction("auth.passkey.register")).toBe("passkey");
|
||||
expect(providerForAction("auth.passkey.delete")).toBe("passkey");
|
||||
});
|
||||
|
||||
it("classifies password actions and failed sign-ins as `password`", () => {
|
||||
expect(providerForAction("auth.login.password")).toBe("password");
|
||||
expect(providerForAction("auth.password.change")).toBe("password");
|
||||
// Failed logins flow through the password endpoint; treating them
|
||||
// as `password` is the truthful summary of how the credential was
|
||||
// offered. A failed passkey attempt has its own audit row.
|
||||
expect(providerForAction("auth.login.failed")).toBe("password");
|
||||
});
|
||||
|
||||
it("classifies bearer + native-token actions as `api_token`", () => {
|
||||
expect(providerForAction("auth.bearer.success")).toBe("api_token");
|
||||
expect(providerForAction("auth.bearer.failure")).toBe("api_token");
|
||||
expect(providerForAction("auth.token.autoissue.native")).toBe("api_token");
|
||||
expect(providerForAction("auth.token.refresh")).toBe("api_token");
|
||||
expect(providerForAction("auth.token.refresh.failed")).toBe("api_token");
|
||||
expect(providerForAction("auth.token.refresh.revoke")).toBe("api_token");
|
||||
expect(providerForAction("auth.token.revoke")).toBe("api_token");
|
||||
});
|
||||
|
||||
it("classifies Withings OAuth events as `withings`", () => {
|
||||
expect(providerForAction("auth.withings.connect")).toBe("withings");
|
||||
expect(providerForAction("auth.withings.disconnect")).toBe("withings");
|
||||
});
|
||||
|
||||
it("falls back to `unknown` for unmapped or generic auth actions", () => {
|
||||
expect(providerForAction("auth.login")).toBe("unknown");
|
||||
expect(providerForAction("auth.logout")).toBe("unknown");
|
||||
expect(providerForAction("auth.register")).toBe("unknown");
|
||||
expect(providerForAction("admin.user.delete")).toBe("unknown");
|
||||
expect(providerForAction("")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("iconForAuthProvider", () => {
|
||||
it("maps each provider to the documented lucide glyph", () => {
|
||||
// Marc's brief pinned the exact icon for each provider so the
|
||||
// visual language stays consistent across the admin surface.
|
||||
expect(iconForAuthProvider("password")).toBe(KeyRound);
|
||||
expect(iconForAuthProvider("passkey")).toBe(Fingerprint);
|
||||
expect(iconForAuthProvider("api_token")).toBe(Cpu);
|
||||
expect(iconForAuthProvider("withings")).toBe(Globe);
|
||||
// `unknown` reuses the OAuth glyph rather than a `?` so the
|
||||
// column never paints a missing icon.
|
||||
expect(iconForAuthProvider("unknown")).toBe(Globe);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PasswordInput as SettingsPasswordInput } from "@/components/settings/password-input";
|
||||
@@ -81,6 +82,9 @@ export interface AdminSettings {
|
||||
bugReportEnabled: boolean;
|
||||
reminderLateMinutes: number;
|
||||
reminderMissedMinutes: number;
|
||||
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver".
|
||||
defaultUserTimezone: string | null;
|
||||
moodLogGlobal?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminAuditEntry {
|
||||
@@ -286,6 +290,189 @@ export function useAuthActionLabels(): Record<string, string> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W8b — Provider column for the Login-Übersicht.
|
||||
*
|
||||
* The audit log doesn't have a dedicated `provider` field; the action
|
||||
* name itself encodes how the login happened (`auth.login.passkey`,
|
||||
* `auth.bearer.success`, …). We derive a coarse provider tag from
|
||||
* the action so admins can see at a glance whether each row is a
|
||||
* password, passkey, API token, or OAuth (Withings) event.
|
||||
*
|
||||
* Keep the mapping exhaustive — every `auth.*` action that lands in
|
||||
* the audit table must resolve to one of `password | passkey |
|
||||
* api_token | withings | unknown`. New actions are wired here once,
|
||||
* mirroring the convention established by `useAuthActionLabels`.
|
||||
*/
|
||||
export type AuthProvider =
|
||||
| "password"
|
||||
| "passkey"
|
||||
| "api_token"
|
||||
| "withings"
|
||||
| "unknown";
|
||||
|
||||
export function providerForAction(action: string): AuthProvider {
|
||||
if (
|
||||
action === "auth.login.passkey" ||
|
||||
action === "auth.passkey.register" ||
|
||||
action === "auth.passkey.delete"
|
||||
) {
|
||||
return "passkey";
|
||||
}
|
||||
if (
|
||||
action === "auth.login.password" ||
|
||||
action === "auth.password.change" ||
|
||||
// Failed sign-ins go through the password endpoint — the passkey
|
||||
// flow has its own `auth.login.failed` row but `details.reason`
|
||||
// disambiguates further; for the column tag, "password" is the
|
||||
// truthful summary of how the credential was offered.
|
||||
action === "auth.login.failed"
|
||||
) {
|
||||
return "password";
|
||||
}
|
||||
if (
|
||||
action === "auth.bearer.success" ||
|
||||
action === "auth.bearer.failure" ||
|
||||
action === "auth.token.autoissue.native" ||
|
||||
action === "auth.token.refresh" ||
|
||||
action === "auth.token.refresh.failed" ||
|
||||
action === "auth.token.refresh.revoke" ||
|
||||
action === "auth.token.revoke"
|
||||
) {
|
||||
return "api_token";
|
||||
}
|
||||
if (action.startsWith("auth.withings")) {
|
||||
return "withings";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const AUTH_PROVIDER_ICONS = {
|
||||
password: KeyRound,
|
||||
passkey: Fingerprint,
|
||||
api_token: Cpu,
|
||||
withings: Globe,
|
||||
unknown: Globe,
|
||||
} as const;
|
||||
|
||||
export function iconForAuthProvider(
|
||||
provider: AuthProvider,
|
||||
): React.ComponentType<{ className?: string }> {
|
||||
return AUTH_PROVIDER_ICONS[provider];
|
||||
}
|
||||
|
||||
/**
|
||||
* i18n label map for the Provider column. Keys match `AuthProvider`
|
||||
* so callers can index directly: `labels[providerForAction(action)]`.
|
||||
*/
|
||||
export function useAuthProviderLabels(): Record<AuthProvider, string> {
|
||||
const { t, locale } = useTranslations();
|
||||
return useMemo(
|
||||
() => ({
|
||||
password: t("admin.providerPassword"),
|
||||
passkey: t("admin.providerPasskey"),
|
||||
api_token: t("admin.providerApiToken"),
|
||||
withings: t("admin.providerWithings"),
|
||||
unknown: t("admin.providerUnknown"),
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[locale],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"],
|
||||
|
||||
@@ -151,8 +151,13 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
const { t } = useTranslations();
|
||||
const activeSlug = deriveActiveSlug(pathname, active);
|
||||
|
||||
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
|
||||
// already, so this inner shell only carries the wider max-width.
|
||||
// Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was
|
||||
// producing visibly more top/bottom whitespace on Settings/Admin
|
||||
// pages than on Dashboard/Insights/Measurements.
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 py-6 md:px-6 md:py-8">
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
{/* Mobile section strip — horizontal scroll, hidden on md+.
|
||||
`no-scrollbar` (defined in `globals.css`) suppresses the
|
||||
painted scrollbar at the top of every admin page; the
|
||||
@@ -170,7 +175,10 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
href="/admin"
|
||||
aria-current={activeSlug === null ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
// v1.4.25 W8 — chip strip is the primary mobile-admin
|
||||
// navigation surface. Pad to WCAG 2.5.5 44 px so the
|
||||
// chips can be tapped without zoom on a Pixel-5.
|
||||
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
activeSlug === null
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
@@ -189,7 +197,10 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
href={`/admin/${section.slug}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
// v1.4.25 W8 — chip strip is the primary mobile-admin
|
||||
// navigation surface. Pad to WCAG 2.5.5 44 px so the
|
||||
// chips can be tapped without zoom on a Pixel-5.
|
||||
"flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border text-foreground hover:bg-accent",
|
||||
|
||||
@@ -64,125 +64,118 @@ export function CoachFeedbackSection() {
|
||||
},
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.coachFeedback.loading")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 rounded-xl border p-4 text-sm"
|
||||
>
|
||||
{t("admin.coachFeedback.loadError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = query.data;
|
||||
const summary = query.data ?? null;
|
||||
const buckets = summary?.coachBuckets ?? [];
|
||||
const hasData = !!summary && buckets.length > 0;
|
||||
|
||||
if (!summary || buckets.length === 0) {
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.coachFeedback.title")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-sm">
|
||||
{t("admin.coachFeedback.empty")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// v1.4.25 W8 — keep the section's outer card + header rendered across
|
||||
// every query state (loading / error / empty / data) so the heading
|
||||
// baseline stays at a constant Y-offset. Previously the loading branch
|
||||
// returned a thin flex-row stub and the error branch a p-4 alert with
|
||||
// NO heading, which made the entire section snap downward when the
|
||||
// fetched data resolved — Marc reported the visible layout shift on
|
||||
// /admin/coach-feedback. Mirrors the canonical structure used by
|
||||
// <SystemStatusSection> (header outside the fetch-state branch).
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.coachFeedback.title")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.coachFeedback.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.coachFeedback.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
{hasData && summary && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.coachFeedback.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.coachFeedback.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-slot="coach-feedback-table">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.coachFeedback.colPromptVersion")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.coachFeedback.colTone")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.coachFeedback.colVerbosity")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colHelpful")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colNotHelpful")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colN")}
|
||||
</th>
|
||||
<th className="py-2 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colHelpfulRate")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{buckets.map((bucket, idx) => {
|
||||
const ratePct = Math.round(bucket.helpfulRate * 100);
|
||||
return (
|
||||
<tr
|
||||
key={`${bucket.promptVersion}-${bucket.tone}-${bucket.verbosity}-${idx}`}
|
||||
className="border-border/40 border-b last:border-0"
|
||||
data-slot="coach-feedback-bucket"
|
||||
>
|
||||
<td className="text-muted-foreground py-2 pr-3 text-xs">
|
||||
{bucket.promptVersion}
|
||||
</td>
|
||||
<td className="py-2 pr-3">{bucket.tone}</td>
|
||||
<td className="py-2 pr-3">{bucket.verbosity}</td>
|
||||
<td className="py-2 pr-3 tabular-nums">{bucket.helpful}</td>
|
||||
<td className="py-2 pr-3 tabular-nums">
|
||||
{bucket.notHelpful}
|
||||
</td>
|
||||
<td className="py-2 pr-3 tabular-nums">{bucket.total}</td>
|
||||
<td
|
||||
className={`py-2 font-semibold tabular-nums ${helpfulRateColour(
|
||||
bucket.helpfulRate,
|
||||
)}`}
|
||||
{query.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.coachFeedback.loading")}
|
||||
</span>
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border p-3 text-sm"
|
||||
>
|
||||
{t("admin.coachFeedback.loadError")}
|
||||
</div>
|
||||
) : !hasData ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.coachFeedback.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-slot="coach-feedback-table">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.coachFeedback.colPromptVersion")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.coachFeedback.colTone")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.coachFeedback.colVerbosity")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colHelpful")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colNotHelpful")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colN")}
|
||||
</th>
|
||||
<th className="py-2 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colHelpfulRate")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{buckets.map((bucket, idx) => {
|
||||
const ratePct = Math.round(bucket.helpfulRate * 100);
|
||||
return (
|
||||
<tr
|
||||
key={`${bucket.promptVersion}-${bucket.tone}-${bucket.verbosity}-${idx}`}
|
||||
className="border-border/40 border-b last:border-0"
|
||||
data-slot="coach-feedback-bucket"
|
||||
>
|
||||
{ratePct}%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<td className="text-muted-foreground py-2 pr-3 text-xs">
|
||||
{bucket.promptVersion}
|
||||
</td>
|
||||
<td className="py-2 pr-3">{bucket.tone}</td>
|
||||
<td className="py-2 pr-3">{bucket.verbosity}</td>
|
||||
<td className="py-2 pr-3 tabular-nums">{bucket.helpful}</td>
|
||||
<td className="py-2 pr-3 tabular-nums">
|
||||
{bucket.notHelpful}
|
||||
</td>
|
||||
<td className="py-2 pr-3 tabular-nums">{bucket.total}</td>
|
||||
<td
|
||||
className={`py-2 font-semibold tabular-nums ${helpfulRateColour(
|
||||
bucket.helpfulRate,
|
||||
)}`}
|
||||
>
|
||||
{ratePct}%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import { listSupportedTimezones } from "@/lib/tz/format";
|
||||
|
||||
const NATIVE_SELECT_CLASS =
|
||||
"border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none";
|
||||
|
||||
export function GeneralSettingsSection() {
|
||||
const { t } = useTranslations();
|
||||
const { data: settings } = useAdminSettings();
|
||||
const updateSettings = useUpdateSettings();
|
||||
// Memoised — `listSupportedTimezones()` calls the engine's
|
||||
// `Intl.supportedValuesOf('timeZone')` which returns ~400 strings.
|
||||
const zones = useMemo(() => listSupportedTimezones(), []);
|
||||
|
||||
const currentDefaultTz = settings?.defaultUserTimezone ?? "";
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
@@ -39,12 +49,47 @@ export function GeneralSettingsSection() {
|
||||
updateSettings.mutate({ defaultLocale: e.target.value })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
className={NATIVE_SELECT_CLASS}
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* v1.4.25 W7 — server-wide default timezone for new
|
||||
signups. Existing accounts are NOT touched; this drives
|
||||
only the registration handler's fallback when the
|
||||
client doesn't send a browser-detected zone. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("admin.defaultUserTimezone")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.defaultUserTimezoneDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={currentDefaultTz}
|
||||
onChange={(e) =>
|
||||
updateSettings.mutate({ defaultUserTimezone: e.target.value })
|
||||
}
|
||||
disabled={updateSettings.isPending}
|
||||
className={NATIVE_SELECT_CLASS}
|
||||
>
|
||||
<option value="">{t("admin.defaultUserTimezoneFallback")}</option>
|
||||
{/* Preserve the stored value even if it's not in the
|
||||
current engine's IANA list (rare — but defensive). */}
|
||||
{currentDefaultTz && !zones.includes(currentDefaultTz) && (
|
||||
<option value={currentDefaultTz}>{currentDefaultTz}</option>
|
||||
)}
|
||||
{zones.map((zone) => (
|
||||
<option key={zone} value={zone}>
|
||||
{zone}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Loader2,
|
||||
MapPin,
|
||||
ScrollText,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
@@ -23,7 +23,17 @@ import {
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { toCSV } from "@/lib/export";
|
||||
import { type AdminAuditEntry, useAuthActionLabels } from "./_shared";
|
||||
import { formatInUserTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
type AdminAuditEntry,
|
||||
auditLogCsvHeaderLabels,
|
||||
buildAuditLogCsvRecords,
|
||||
iconForAuthProvider,
|
||||
providerForAction,
|
||||
useAuthActionLabels,
|
||||
useAuthProviderLabels,
|
||||
} from "./_shared";
|
||||
|
||||
type DateRangePreset = "all" | "24h" | "7d" | "30d";
|
||||
type PerPageValue = 25 | 50 | 100;
|
||||
@@ -50,11 +60,20 @@ function rangeToSince(range: DateRangePreset): string | undefined {
|
||||
|
||||
export function LoginOverviewSection() {
|
||||
const { t } = useTranslations();
|
||||
// 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
|
||||
// stays as an escape hatch.
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
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.4.25 W8b — the section used to wrap its body in a
|
||||
// collapse/expand toggle. Since v1.5 phase-4b moved this to a
|
||||
// dedicated `/admin/login-overview` route (the only thing on the
|
||||
// page), the toggle was pure clutter: visiting the page already
|
||||
// signals intent to see the audit log. The toggle is gone; content
|
||||
// always renders. The `settings.collapse` / `settings.expand`
|
||||
// i18n keys are retained centrally for any future surface.
|
||||
|
||||
// Quick-filter pill (kept from the v1.4.x UI for one-tap "show failed").
|
||||
const [filter, setFilter] = useState<"all" | "failed">("all");
|
||||
@@ -68,6 +87,7 @@ export function LoginOverviewSection() {
|
||||
const [perPage, setPerPage] = useState<PerPageValue>(50);
|
||||
|
||||
const AUTH_ACTION_LABELS = useAuthActionLabels();
|
||||
const AUTH_PROVIDER_LABELS = useAuthProviderLabels();
|
||||
|
||||
// Build the query string once so it's reused by the data-query key, the
|
||||
// export download, and the next-/prev- buttons.
|
||||
@@ -115,7 +135,6 @@ export function LoginOverviewSection() {
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as AuditLogResponse;
|
||||
},
|
||||
enabled: expanded,
|
||||
});
|
||||
|
||||
// Distinct actions for the dropdown — populated lazily.
|
||||
@@ -126,7 +145,6 @@ export function LoginOverviewSection() {
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as { actions: string[] };
|
||||
},
|
||||
enabled: expanded,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
@@ -136,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");
|
||||
@@ -167,292 +193,299 @@ export function LoginOverviewSection() {
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="text-primary h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.loginOverview")}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? t("settings.collapse") : t("settings.expand")}
|
||||
<ChevronDown
|
||||
className={`ml-1 h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="text-primary h-5 w-5" />
|
||||
<div className="text-lg font-semibold">{t("admin.loginOverview")}</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Quick-filter pills. Failed-only is the most common
|
||||
<div className="mt-4 space-y-3">
|
||||
{/* Quick-filter pills. Failed-only is the most common
|
||||
admin "is something wrong" question — keep the one-tap shortcut. */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filter === "all" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={resetPageOnFilterChange(() => setFilter("all"))}
|
||||
>
|
||||
{t("admin.allAuthEvents")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "failed" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={resetPageOnFilterChange(() => setFilter("failed"))}
|
||||
>
|
||||
{t("admin.failedOnly")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={filter === "all" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={resetPageOnFilterChange(() => setFilter("all"))}
|
||||
>
|
||||
{t("admin.allAuthEvents")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "failed" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 min-w-11 px-3 text-xs"
|
||||
onClick={resetPageOnFilterChange(() => setFilter("failed"))}
|
||||
>
|
||||
{t("admin.failedOnly")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Detailed filter row */}
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.auditLog.filterActor")}
|
||||
value={actor}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setActor(e.target.value);
|
||||
}}
|
||||
aria-label={t("admin.section.auditLog.filterActor")}
|
||||
/>
|
||||
{/* Detailed filter row */}
|
||||
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.auditLog.filterActor")}
|
||||
value={actor}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setActor(e.target.value);
|
||||
}}
|
||||
aria-label={t("admin.section.auditLog.filterActor")}
|
||||
/>
|
||||
<Select
|
||||
value={actionFilter || "__all__"}
|
||||
onValueChange={(v) => {
|
||||
setPage(1);
|
||||
setActionFilter(v === "__all__" ? "" : v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("admin.section.auditLog.filterAction")}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t("admin.section.auditLog.filterAction")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">
|
||||
{t("admin.section.auditLog.filterActionAll")}
|
||||
</SelectItem>
|
||||
{(actionsData?.actions ?? [])
|
||||
.filter((a) => a.startsWith("auth."))
|
||||
.map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{AUTH_ACTION_LABELS[a] ?? a}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.auditLog.filterTarget")}
|
||||
value={target}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setTarget(e.target.value);
|
||||
}}
|
||||
aria-label={t("admin.section.auditLog.filterTarget")}
|
||||
/>
|
||||
<Select
|
||||
value={range}
|
||||
onValueChange={(v) => {
|
||||
setPage(1);
|
||||
setRange(v as DateRangePreset);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={t("admin.section.auditLog.filterDate")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="24h">
|
||||
{t("admin.section.auditLog.range24h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="7d">
|
||||
{t("admin.section.auditLog.range7d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="30d">
|
||||
{t("admin.section.auditLog.range30d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="all">
|
||||
{t("admin.section.auditLog.rangeAll")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Toolbar row: per-page + export */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("admin.section.auditLog.perPage")}
|
||||
</span>
|
||||
<Select
|
||||
value={actionFilter || "__all__"}
|
||||
value={String(perPage)}
|
||||
onValueChange={(v) => {
|
||||
setPage(1);
|
||||
setActionFilter(v === "__all__" ? "" : v);
|
||||
setPerPage(Number(v) as PerPageValue);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("admin.section.auditLog.filterAction")}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t("admin.section.auditLog.filterAction")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">
|
||||
{t("admin.section.auditLog.filterActionAll")}
|
||||
</SelectItem>
|
||||
{(actionsData?.actions ?? [])
|
||||
.filter((a) => a.startsWith("auth."))
|
||||
.map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{AUTH_ACTION_LABELS[a] ?? a}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.section.auditLog.filterTarget")}
|
||||
value={target}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setTarget(e.target.value);
|
||||
}}
|
||||
aria-label={t("admin.section.auditLog.filterTarget")}
|
||||
/>
|
||||
<Select
|
||||
value={range}
|
||||
onValueChange={(v) => {
|
||||
setPage(1);
|
||||
setRange(v as DateRangePreset);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t("admin.section.auditLog.filterDate")}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="24h">
|
||||
{t("admin.section.auditLog.range24h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="7d">
|
||||
{t("admin.section.auditLog.range7d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="30d">
|
||||
{t("admin.section.auditLog.range30d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="all">
|
||||
{t("admin.section.auditLog.rangeAll")}
|
||||
</SelectItem>
|
||||
{PER_PAGE_OPTIONS.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadCsv}
|
||||
disabled={entries.length === 0}
|
||||
>
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
{t("admin.section.auditLog.export")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar row: per-page + export */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("admin.section.auditLog.perPage")}
|
||||
</span>
|
||||
<Select
|
||||
value={String(perPage)}
|
||||
onValueChange={(v) => {
|
||||
setPage(1);
|
||||
setPerPage(Number(v) as PerPageValue);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PER_PAGE_OPTIONS.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadCsv}
|
||||
disabled={entries.length === 0}
|
||||
>
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
{t("admin.section.auditLog.export")}
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !entries.length ? (
|
||||
<EmptyState
|
||||
icon={<ScrollText className="size-6" />}
|
||||
title={
|
||||
filter === "failed"
|
||||
? t("admin.loginEmptyFailedTitle")
|
||||
: t("admin.section.auditLog.empty")
|
||||
}
|
||||
description={
|
||||
filter === "failed"
|
||||
? t("admin.loginEmptyFailedDescription")
|
||||
: t("admin.loginEmptyDescription")
|
||||
}
|
||||
action={
|
||||
filter === "failed" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetPageOnFilterChange(() => setFilter("all"))}
|
||||
>
|
||||
{t("admin.loginEmptyResetFilter")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.action")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.ip")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
) : !entries.length ? (
|
||||
<EmptyState
|
||||
icon={<ScrollText className="size-6" />}
|
||||
title={
|
||||
filter === "failed"
|
||||
? t("admin.loginEmptyFailedTitle")
|
||||
: t("admin.section.auditLog.empty")
|
||||
}
|
||||
description={
|
||||
filter === "failed"
|
||||
? t("admin.loginEmptyFailedDescription")
|
||||
: t("admin.loginEmptyDescription")
|
||||
}
|
||||
action={
|
||||
filter === "failed" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetPageOnFilterChange(() => setFilter("all"))}
|
||||
>
|
||||
{t("admin.loginEmptyResetFilter")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.users")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.action")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.provider")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.ip")}
|
||||
</th>
|
||||
{/*
|
||||
v1.4.25 W8b — Standort column. Previously rendered
|
||||
`text-right`, which on wide audit tables let the
|
||||
`Berlin, DE` label drift to the table edge and
|
||||
blend into the timestamp gutter. Left-aligning it
|
||||
(and dropping the `text-right` from IP for
|
||||
consistency) makes Standort a first-class column
|
||||
that admins actually notice.
|
||||
*/}
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" aria-hidden="true" />
|
||||
{t("admin.location")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.timestamp")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{entries.map((entry, i) => {
|
||||
const isFailed = entry.action === "auth.login.failed";
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.timestamp")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{entries.map((entry, i) => {
|
||||
const isFailed = entry.action === "auth.login.failed";
|
||||
const provider = providerForAction(entry.action);
|
||||
const ProviderIcon = iconForAuthProvider(provider);
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
{isFailed ? (
|
||||
<XCircle className="text-destructive h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="text-dracula-green h-4 w-4" />
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
{isFailed ? (
|
||||
<XCircle className="text-destructive h-4 w-4" />
|
||||
) : (
|
||||
<CheckCircle2 className="text-dracula-green h-4 w-4" />
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 font-medium ${isFailed ? "text-destructive" : ""}`}
|
||||
>
|
||||
{entry.user?.username ?? t("common.unknown")}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right font-mono text-xs">
|
||||
{entry.ipAddress ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs">
|
||||
{entry.location ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDateTime(entry.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
{entry.user?.username ?? t("common.unknown")}
|
||||
</td>
|
||||
<td
|
||||
className={`px-3 py-2 text-xs ${isFailed ? "text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
{AUTH_ACTION_LABELS[entry.action] ?? entry.action}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProviderIcon
|
||||
className="h-3.5 w-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{AUTH_PROVIDER_LABELS[provider]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 font-mono text-xs">
|
||||
{entry.ipAddress ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{entry.location ?? "—"}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
|
||||
{formatDateTime(entry.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<span>
|
||||
{t("admin.showingEntries", {
|
||||
count: entries.length,
|
||||
total,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{t("admin.section.auditLog.prev")}
|
||||
</Button>
|
||||
<span>
|
||||
{t("admin.showingEntries", {
|
||||
count: entries.length,
|
||||
total,
|
||||
{t("admin.section.auditLog.pageOf", {
|
||||
page,
|
||||
total: lastPage,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
{t("admin.section.auditLog.prev")}
|
||||
</Button>
|
||||
<span>
|
||||
{t("admin.section.auditLog.pageOf", {
|
||||
page,
|
||||
total: lastPage,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= lastPage}
|
||||
onClick={() => setPage((p) => Math.min(lastPage, p + 1))}
|
||||
>
|
||||
{t("admin.section.auditLog.next")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= lastPage}
|
||||
onClick={() => setPage((p) => Math.min(lastPage, p + 1))}
|
||||
>
|
||||
{t("admin.section.auditLog.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user