feat(admin): add Provider column to Login-Übersicht

The audit log records *how* each login happened in the action name
(`auth.login.passkey`, `auth.bearer.success`, …) but the table
collapsed all of that into a single Aktion label. Admins now see
the credential type as a separate column with a lucide glyph
(KeyRound / Fingerprint / Cpu / Globe) so password-vs-passkey-vs-API
token attempts are distinguishable at a glance — and the column
maps cleanly into the CSV export.

The mapping itself (`providerForAction`) lives in `_shared.tsx`
alongside `useAuthActionLabels` so any later audit-feed consumer
picks up the same i18n + icon contract; a regression test pins
every action → provider edge and every provider → icon edge.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 12:49:51 +02:00
1 parent a19768c5d4
commit b5062ead1c
5 files changed
+196 -1

No files matched your search

+6
View File
@@ -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.",
+6
View File
@@ -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.",
@@ -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);
});
});
+91
View File
@@ -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<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],
);
}
export function useSystemStatus() {
return useQuery({
queryKey: ["admin", "status"],
@@ -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<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.
@@ -368,6 +375,9 @@ export function LoginOverviewSection() {
<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>
@@ -394,6 +404,8 @@ export function LoginOverviewSection() {
<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}
@@ -416,6 +428,15 @@ export function LoginOverviewSection() {
>
{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>