mirror of
https://github.com/agentrhq/authsome.git
synced 2026-09-19 01:34:19 +08:00
feat: show scoped audit log in dashboard
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user