diff --git a/messages/de.json b/messages/de.json index 05d5b9a48..d0fdabdf1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1921,6 +1921,12 @@ "ip": "IP", "location": "Standort", "timestamp": "Zeitpunkt", + "provider": "Anbieter", + "providerPassword": "Passwort", + "providerPasskey": "Passkey", + "providerApiToken": "API-Token", + "providerWithings": "Withings", + "providerUnknown": "Unbekannt", "dangerZone": "Gefahrenzone", "deleteAllData": "Alle Daten global löschen", "deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Benutzer. Benutzerkonten bleiben bestehen.", diff --git a/messages/en.json b/messages/en.json index e3168a18e..5bd660070 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1921,6 +1921,12 @@ "ip": "IP", "location": "Location", "timestamp": "Time", + "provider": "Provider", + "providerPassword": "Password", + "providerPasskey": "Passkey", + "providerApiToken": "API token", + "providerWithings": "Withings", + "providerUnknown": "Unknown", "dangerZone": "Danger Zone", "deleteAllData": "Delete all data globally", "deleteAllDescription": "Deletes all measurement, medication, integration, and log data for all users. User accounts will be preserved.", diff --git a/src/components/admin/__tests__/login-overview-provider.test.tsx b/src/components/admin/__tests__/login-overview-provider.test.tsx new file mode 100644 index 000000000..59d365451 --- /dev/null +++ b/src/components/admin/__tests__/login-overview-provider.test.tsx @@ -0,0 +1,71 @@ +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); + }); +}); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 10728d3dc..671801fa0 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -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"; @@ -289,6 +290,96 @@ export function useAuthActionLabels(): Record { ); } +/** + * 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 { + 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], + ); +} + export function useSystemStatus() { return useQuery({ queryKey: ["admin", "status"], diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index 1a463bc96..c3a54e2c1 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -24,7 +24,13 @@ import { import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { toCSV } from "@/lib/export"; -import { type AdminAuditEntry, useAuthActionLabels } from "./_shared"; +import { + type AdminAuditEntry, + iconForAuthProvider, + providerForAction, + useAuthActionLabels, + useAuthProviderLabels, +} from "./_shared"; type DateRangePreset = "all" | "24h" | "7d" | "30d"; type PerPageValue = 25 | 50 | 100; @@ -69,6 +75,7 @@ export function LoginOverviewSection() { const [perPage, setPerPage] = useState(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. @@ -368,6 +375,9 @@ export function LoginOverviewSection() { {t("admin.action")} + + {t("admin.provider")} + {t("admin.ip")} @@ -394,6 +404,8 @@ export function LoginOverviewSection() { {entries.map((entry, i) => { const isFailed = entry.action === "auth.login.failed"; + const provider = providerForAction(entry.action); + const ProviderIcon = iconForAuthProvider(provider); return ( {AUTH_ACTION_LABELS[entry.action] ?? entry.action} + + + + {entry.ipAddress ?? "—"}