mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
a19768c5d4
commit
b5062ead1c
5 files changed
+196
-1
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user