feat: show scoped audit log in dashboard

This commit is contained in:
beubax
2026-06-15 13:55:49 +05:30
parent 08446d1e28
commit 0b8d752392
3 changed files with 277 additions and 11 deletions
+1 -1
View File
@@ -7,6 +7,6 @@ import { fetchDashboard } from "@/lib/authsome-api";
export default function AuditPage() {
const { data } = useSWR("authsome-dashboard", fetchDashboard);
if (!data || !data.account.isAdmin) return null;
if (!data) return null;
return <AuditView data={data} />;
}
+224 -6
View File
@@ -26,11 +26,12 @@ import {
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { FormEvent, ReactNode, useEffect, useMemo, useState } from "react";
import { FormEvent, ReactNode, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import {
ApiError,
AuditFilters,
ConnectionDetail,
DashboardData,
GlobalConnectionRow,
@@ -38,6 +39,7 @@ import {
ProviderDetail,
ProviderView,
SessionInputField,
fetchAuditEvents,
fetchAuthSessionStatus,
fetchClaimStatus,
fetchConnectionDetail,
@@ -105,7 +107,7 @@ const NAV_ITEMS: NavItem[] = [
{ id: "agents", href: "/agents", label: "Agents", icon: <UserRound /> },
{ id: "principals", href: "/principal", label: "Principals", icon: <Users />, adminOnly: true },
{ id: "vault", href: "/vault", label: "Vault", icon: <Database /> },
{ id: "audit", href: "/audit", label: "Audit Log", icon: <ClipboardList />, adminOnly: true },
{ id: "audit", href: "/audit", label: "Audit Log", icon: <ClipboardList /> },
{ id: "settings", href: "/settings", label: "Settings", icon: <Settings /> },
];
@@ -1412,13 +1414,222 @@ export function VaultView({ data }: { data: DashboardData }) {
);
}
function normalizeAuditFilters(filters: AuditFilters): AuditFilters {
return Object.fromEntries(
Object.entries(filters)
.map(([key, value]) => [key, typeof value === "string" ? value.trim() : value])
.filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== ""),
) as AuditFilters;
}
function localDateTimeToIso(value: string): string | undefined {
if (!value) return undefined;
const parsed = new Date(value);
return Number.isNaN(parsed.valueOf()) ? undefined : parsed.toISOString();
}
export function AuditView({ data }: { data: DashboardData }) {
const [filters, setFilters] = useState<AuditFilters>({
event: "",
provider: "",
identity: "",
from: "",
to: "",
});
const [auditResult, setAuditResult] = useState<{
activeFilters: AuditFilters;
events: DashboardData["audit"]["events"];
nextCursor: string | null;
} | null>(null);
const [errorMessage, setErrorMessage] = useState("");
const [loadingAction, setLoadingAction] = useState<"apply" | "more" | null>(null);
const requestSequence = useRef(0);
const events = auditResult?.events ?? data.audit.events;
const nextCursor = auditResult?.nextCursor ?? data.audit.nextCursor;
const activeFilters = auditResult?.activeFilters ?? {};
const providerNames = useMemo(() => data.providers.map((provider) => provider.name).sort(), [data.providers]);
const identityHandles = useMemo(() => data.identities.map((identity) => identity.handle).sort(), [data.identities]);
const eventNames = useMemo(() => Array.from(new Set(events.map((event) => event.eventName))).sort(), [events]);
const description = data.account.isAdmin
? "Recent administrative and credential events."
: "Recent account, identity, vault, and credential events for this principal.";
function updateFilter(name: keyof Pick<AuditFilters, "event" | "provider" | "identity" | "from" | "to">, value: string) {
setFilters((current) => ({ ...current, [name]: value }));
}
function filtersForRequest(cursor?: string | null): AuditFilters {
return normalizeAuditFilters({
event: filters.event,
provider: filters.provider,
identity: filters.identity,
from: localDateTimeToIso(filters.from || ""),
to: localDateTimeToIso(filters.to || ""),
cursor,
limit: 50,
});
}
function nextRequestId(): number {
requestSequence.current += 1;
return requestSequence.current;
}
function isLatestRequest(requestId: number): boolean {
return requestSequence.current === requestId;
}
async function applyFilters(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const requestFilters = filtersForRequest();
const requestId = nextRequestId();
setLoadingAction("apply");
setErrorMessage("");
try {
const result = await fetchAuditEvents(requestFilters);
if (!isLatestRequest(requestId)) return;
setAuditResult({
activeFilters: requestFilters,
events: result.events,
nextCursor: result.nextCursor,
});
} catch (error) {
if (!isLatestRequest(requestId)) return;
setErrorMessage(error instanceof Error ? error.message : "Failed to load audit events.");
} finally {
if (isLatestRequest(requestId)) {
setLoadingAction(null);
}
}
}
async function clearFilters() {
const emptyFilters = { event: "", provider: "", identity: "", from: "", to: "" };
const requestId = nextRequestId();
setFilters(emptyFilters);
setLoadingAction("apply");
setErrorMessage("");
try {
const result = await fetchAuditEvents({ limit: 50 });
if (!isLatestRequest(requestId)) return;
setAuditResult({
activeFilters: {},
events: result.events,
nextCursor: result.nextCursor,
});
} catch (error) {
if (!isLatestRequest(requestId)) return;
setErrorMessage(error instanceof Error ? error.message : "Failed to load audit events.");
} finally {
if (isLatestRequest(requestId)) {
setLoadingAction(null);
}
}
}
async function loadMore() {
if (!nextCursor) return;
const requestId = nextRequestId();
setLoadingAction("more");
setErrorMessage("");
try {
const result = await fetchAuditEvents({ ...activeFilters, cursor: nextCursor, limit: 50 });
if (!isLatestRequest(requestId)) return;
setAuditResult({
activeFilters,
events: [...events, ...result.events],
nextCursor: result.nextCursor,
});
} catch (error) {
if (!isLatestRequest(requestId)) return;
setErrorMessage(error instanceof Error ? error.message : "Failed to load more audit events.");
} finally {
if (isLatestRequest(requestId)) {
setLoadingAction(null);
}
}
}
return (
<div className="grid gap-5">
<SectionHeader description="Recent administrative and credential events." title="Audit Log" />
<SectionHeader description={description} title="Audit Log" />
<Card className="shadow-none border-border/50">
<CardHeader>
<CardTitle>Filters</CardTitle>
<CardDescription>{data.audit.scope === "global" ? "Global audit scope" : "Principal audit scope"}</CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-4 md:grid-cols-2 xl:grid-cols-5" onSubmit={(event) => void applyFilters(event)}>
<label className="grid gap-2 text-sm">
<span className="text-muted-foreground">Event</span>
<Input
list="audit-event-options"
onChange={(event) => updateFilter("event", event.target.value)}
value={filters.event || ""}
/>
</label>
<label className="grid gap-2 text-sm">
<span className="text-muted-foreground">Provider</span>
<Input
list="audit-provider-options"
onChange={(event) => updateFilter("provider", event.target.value)}
value={filters.provider || ""}
/>
</label>
<label className="grid gap-2 text-sm">
<span className="text-muted-foreground">Identity</span>
<Input
list="audit-identity-options"
onChange={(event) => updateFilter("identity", event.target.value)}
value={filters.identity || ""}
/>
</label>
<label className="grid gap-2 text-sm">
<span className="text-muted-foreground">From</span>
<Input
onChange={(event) => updateFilter("from", event.target.value)}
type="datetime-local"
value={filters.from || ""}
/>
</label>
<label className="grid gap-2 text-sm">
<span className="text-muted-foreground">To</span>
<Input
onChange={(event) => updateFilter("to", event.target.value)}
type="datetime-local"
value={filters.to || ""}
/>
</label>
<datalist id="audit-event-options">
{eventNames.map((event) => (
<option key={event} value={event} />
))}
</datalist>
<datalist id="audit-provider-options">
{providerNames.map((provider) => (
<option key={provider} value={provider} />
))}
</datalist>
<datalist id="audit-identity-options">
{identityHandles.map((identity) => (
<option key={identity} value={identity} />
))}
</datalist>
<div className="flex items-end gap-2 md:col-span-2 xl:col-span-5">
<Button disabled={loadingAction !== null} type="submit">
Apply filters
</Button>
<Button disabled={loadingAction !== null} onClick={() => void clearFilters()} type="button" variant="outline">
Clear
</Button>
</div>
</form>
{errorMessage ? <p className="mt-4 text-sm text-destructive">{errorMessage}</p> : null}
</CardContent>
</Card>
<Card className="shadow-none border-border/50">
<CardContent className="p-0">
{data.audit.events.length ? (
{events.length ? (
<Table>
<TableHeader>
<TableRow>
@@ -1430,7 +1641,7 @@ export function AuditView({ data }: { data: DashboardData }) {
</TableRow>
</TableHeader>
<TableBody>
{data.audit.events.map((event) => (
{events.map((event) => (
<TableRow key={event.eventId}>
<TableCell className="whitespace-nowrap font-mono text-xs text-muted-foreground">{event.time}</TableCell>
<TableCell className="font-medium">{event.event}</TableCell>
@@ -1458,6 +1669,13 @@ export function AuditView({ data }: { data: DashboardData }) {
)}
</CardContent>
</Card>
{nextCursor ? (
<div className="flex justify-center">
<Button disabled={loadingAction !== null} onClick={() => void loadMore()} type="button" variant="outline">
{loadingAction === "more" ? "Loading..." : "Load more"}
</Button>
</div>
) : null}
</div>
);
}
@@ -2051,7 +2269,7 @@ function ActiveView({ connectionFilter, data, onRefresh, view }: {
if (view === "agents") return <AgentsView data={data} />;
if (view === "principals") return <PrincipalsView />;
if (view === "vault") return <VaultView data={data} />;
if (view === "audit" && data.account.isAdmin) return <AuditView data={data} />;
if (view === "audit") return <AuditView data={data} />;
if (view === "settings") return <SettingsView data={data} />;
return <DashboardView data={data} />;
}
+52 -4
View File
@@ -42,6 +42,7 @@ export type IdentityRow = {
export type AuditRow = {
eventId: string;
time: string;
eventName: string;
event: string;
source: string;
actor: string;
@@ -50,6 +51,23 @@ export type AuditRow = {
metadata: Record<string, unknown>;
};
export type AuditFilters = {
event?: string;
provider?: string;
identity?: string;
from?: string;
to?: string;
cursor?: string | null;
limit?: number;
};
export type AuditEventsData = {
scope: "global" | "principal";
nextCursor: string | null;
events: AuditRow[];
total: number;
};
export type DashboardData = {
version: string;
account: {
@@ -73,6 +91,8 @@ export type DashboardData = {
};
audit: {
canView: boolean;
scope: "global" | "principal";
nextCursor: string | null;
total: number;
events: AuditRow[];
};
@@ -216,6 +236,8 @@ type ConnectionsResponse = {
type AuditResponse = {
entries: Array<Record<string, unknown>>;
next_cursor?: string | null;
scope?: "global" | "principal";
};
export type PrincipalRow = {
@@ -472,6 +494,7 @@ function buildAuditRows(entries: AuditResponse["entries"]): AuditRow[] {
return {
eventId: String(entry.event_id || `${entry.timestamp || "event"}-${index}`),
time: formatAuditTime(entry.timestamp),
eventName: String(entry.event || "audit_event"),
event: humanize(entry.event),
source: String(entry.source || "internal"),
actor: String(entry.identity || entry.principal_id || "system"),
@@ -482,6 +505,29 @@ function buildAuditRows(entries: AuditResponse["entries"]): AuditRow[] {
});
}
function auditQueryString(filters: AuditFilters = {}): string {
const params = new URLSearchParams();
params.set("limit", String(filters.limit ?? 50));
if (filters.event) params.set("event", filters.event);
if (filters.provider) params.set("provider", filters.provider);
if (filters.identity) params.set("identity", filters.identity);
if (filters.from) params.set("from", filters.from);
if (filters.to) params.set("to", filters.to);
if (filters.cursor) params.set("cursor", filters.cursor);
return params.toString();
}
export async function fetchAuditEvents(filters: AuditFilters = {}): Promise<AuditEventsData> {
const data = await requestJson<AuditResponse>(`/api/audit/events?${auditQueryString(filters)}`);
const events = buildAuditRows(data.entries);
return {
scope: data.scope ?? "principal",
nextCursor: data.next_cursor ?? null,
events,
total: events.length,
};
}
function roleLabel(role: string | undefined): string | null {
if (!role) {
return null;
@@ -496,7 +542,7 @@ export async function fetchDashboard(): Promise<DashboardData> {
requestJson<ConnectionsResponse>("/api/connections"),
]);
const isAdmin = whoami.principal_role === "admin";
const audit = isAdmin ? await requestJson<AuditResponse>("/api/audit/events?limit=100") : { entries: [] };
const audit = await fetchAuditEvents({ limit: 100 });
const providers = buildProviders(connectionsData);
const connections = buildConnectionRows(connectionsData, providers);
const globalConnections = buildGlobalConnectionRows(connectionsData);
@@ -534,9 +580,11 @@ export async function fetchDashboard(): Promise<DashboardData> {
isDefault: true,
},
audit: {
canView: isAdmin,
total: audit.entries.length,
events: buildAuditRows(audit.entries),
canView: true,
scope: audit.scope,
nextCursor: audit.nextCursor,
total: audit.total,
events: audit.events,
},
};
}