forked from baron/baron-sso
496 lines
18 KiB
TypeScript
496 lines
18 KiB
TypeScript
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
|
|
import * as React from "react";
|
|
import {
|
|
type CommonBadgeVariant,
|
|
getCommonBadgeClasses,
|
|
} from "../../../ui/badge";
|
|
import { getCommonButtonClasses } from "../../../ui/button";
|
|
import {
|
|
commonStickyTableHeaderClass,
|
|
commonTableBodyClass,
|
|
commonTableCellClass,
|
|
commonTableClass,
|
|
commonTableHeadClass,
|
|
commonTableHeaderClass,
|
|
commonTableRowClass,
|
|
commonTableShellClass,
|
|
commonTableViewportClass,
|
|
commonTableWrapperClass,
|
|
} from "../../../ui/table";
|
|
import type { CommonAuditLog } from "../../audit";
|
|
import {
|
|
formatAuditDateParts,
|
|
formatAuditValue,
|
|
parseAuditDetails,
|
|
resolveAuditAction,
|
|
resolveAuditActor,
|
|
resolveAuditTarget,
|
|
} from "../../audit";
|
|
|
|
type AuditTranslate = (
|
|
key: string,
|
|
fallback: string,
|
|
vars?: Record<string, string | number>,
|
|
) => string;
|
|
|
|
type AuditLogTableProps = {
|
|
logs: CommonAuditLog[];
|
|
t: AuditTranslate;
|
|
loading: boolean;
|
|
hasNextPage: boolean;
|
|
isFetchingNextPage: boolean;
|
|
onLoadMore: () => void;
|
|
className?: string;
|
|
};
|
|
|
|
function cx(...classNames: Array<string | false | null | undefined>) {
|
|
return classNames.filter(Boolean).join(" ");
|
|
}
|
|
|
|
function statusVariant(status: string): CommonBadgeVariant {
|
|
switch (status.toLowerCase()) {
|
|
case "success":
|
|
case "ok":
|
|
return "success";
|
|
case "failure":
|
|
case "error":
|
|
case "blocked":
|
|
return "destructive";
|
|
case "pending":
|
|
case "warning":
|
|
return "warning";
|
|
default:
|
|
return "default";
|
|
}
|
|
}
|
|
|
|
export function AuditLogTable({
|
|
logs,
|
|
t,
|
|
loading,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
onLoadMore,
|
|
className,
|
|
}: AuditLogTableProps) {
|
|
const [expandedRows, setExpandedRows] = React.useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
|
|
const handleCopy = (value: string) => {
|
|
if (!value) {
|
|
return;
|
|
}
|
|
navigator.clipboard.writeText(value);
|
|
};
|
|
|
|
return (
|
|
<div className={cx(commonTableShellClass, className)}>
|
|
<div className={cx(commonTableViewportClass, "flex-1")}>
|
|
<div className={commonTableWrapperClass}>
|
|
<Table className={commonTableClass}>
|
|
<TableHeader className={commonTableHeaderClass}>
|
|
<TableRow
|
|
className={cx(
|
|
commonTableRowClass,
|
|
commonStickyTableHeaderClass,
|
|
)}
|
|
>
|
|
<TableHead className={cx(commonTableHeadClass, "w-[190px]")}>
|
|
{t("ui.common.audit.table.time", "Time")}
|
|
</TableHead>
|
|
<TableHead className={cx(commonTableHeadClass, "w-[180px]")}>
|
|
{t("ui.common.audit.table.user_id", "User ID")}
|
|
</TableHead>
|
|
<TableHead className={cx(commonTableHeadClass, "w-[180px]")}>
|
|
{t("ui.common.audit.table.action", "Action")}
|
|
</TableHead>
|
|
<TableHead className={cx(commonTableHeadClass, "w-[260px]")}>
|
|
{t("ui.common.audit.table.client_id", "Client ID")}
|
|
</TableHead>
|
|
<TableHead className={cx(commonTableHeadClass, "w-[120px]")}>
|
|
{t("ui.common.audit.table.status", "Status")}
|
|
</TableHead>
|
|
<TableHead className={cx(commonTableHeadClass, "w-[80px]")} />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody className={commonTableBodyClass}>
|
|
{logs.map((log, index) => {
|
|
const details = parseAuditDetails(log.details);
|
|
const actorLabel = resolveAuditActor(log, details);
|
|
const actionLabel = resolveAuditAction(log, details);
|
|
const targetLabel = resolveAuditTarget(details);
|
|
const rowKey = `${log.event_id}-${log.timestamp}-${index}`;
|
|
const expanded = Boolean(expandedRows[rowKey]);
|
|
const { date, time } = formatAuditDateParts(log.timestamp);
|
|
|
|
return (
|
|
<React.Fragment key={rowKey}>
|
|
<TableRow className={cx(commonTableRowClass, "bg-card/40")}>
|
|
<TableCell
|
|
className={cx(
|
|
commonTableCellClass,
|
|
"text-xs text-muted-foreground",
|
|
)}
|
|
>
|
|
<div className="space-y-1">
|
|
<div>{date}</div>
|
|
<div>{time}</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className={commonTableCellClass}>
|
|
<div className="flex items-center gap-2">
|
|
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
|
{actorLabel}
|
|
</code>
|
|
{actorLabel !== "-" ? (
|
|
<button
|
|
type="button"
|
|
className={cx(
|
|
getCommonButtonClasses({
|
|
variant: "ghost",
|
|
size: "icon",
|
|
}),
|
|
"h-7 w-7 text-muted-foreground hover:text-primary",
|
|
)}
|
|
aria-label={t(
|
|
"ui.common.audit.copy.actor_id",
|
|
"Copy User ID",
|
|
)}
|
|
onClick={() => handleCopy(actorLabel)}
|
|
>
|
|
<Copy className="h-3 w-3" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell
|
|
className={cx(
|
|
commonTableCellClass,
|
|
"text-xs text-muted-foreground",
|
|
)}
|
|
>
|
|
<div className="font-semibold text-foreground">
|
|
{actionLabel}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell
|
|
className={cx(
|
|
commonTableCellClass,
|
|
"text-xs text-muted-foreground",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="break-all">{targetLabel}</span>
|
|
{targetLabel !== "-" ? (
|
|
<button
|
|
type="button"
|
|
className={cx(
|
|
getCommonButtonClasses({
|
|
variant: "ghost",
|
|
size: "icon",
|
|
}),
|
|
"h-7 w-7 text-muted-foreground hover:text-primary",
|
|
)}
|
|
aria-label={t(
|
|
"ui.common.audit.copy.target",
|
|
"Copy Client ID",
|
|
)}
|
|
onClick={() => handleCopy(targetLabel)}
|
|
>
|
|
<Copy className="h-3 w-3" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className={commonTableCellClass}>
|
|
<span
|
|
className={getCommonBadgeClasses({
|
|
variant: statusVariant(log.status),
|
|
})}
|
|
>
|
|
{log.status}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell
|
|
className={cx(commonTableCellClass, "text-right")}
|
|
>
|
|
<button
|
|
type="button"
|
|
className={getCommonButtonClasses({
|
|
variant: "ghost",
|
|
size: "sm",
|
|
})}
|
|
onClick={() =>
|
|
setExpandedRows((prev) => ({
|
|
...prev,
|
|
[rowKey]: !expanded,
|
|
}))
|
|
}
|
|
>
|
|
{expanded ? (
|
|
<ChevronUp className="h-4 w-4" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</TableCell>
|
|
</TableRow>
|
|
{expanded && (
|
|
<TableRow
|
|
className={cx(commonTableRowClass, "bg-card/20")}
|
|
>
|
|
<TableCell
|
|
colSpan={6}
|
|
className={cx(commonTableCellClass, "text-xs")}
|
|
>
|
|
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
|
|
<div className="space-y-1">
|
|
<div className="uppercase tracking-[0.16em]">
|
|
{t(
|
|
"ui.common.audit.details.request",
|
|
"Request",
|
|
)}
|
|
</div>
|
|
<div className="break-all">
|
|
{t(
|
|
"ui.common.audit.details.request_id",
|
|
"Request ID · {{value}}",
|
|
{
|
|
value: formatAuditValue(details.request_id),
|
|
},
|
|
)}
|
|
</div>
|
|
<div className="break-all">
|
|
{t(
|
|
"ui.common.audit.details.event_id",
|
|
"Event ID · {{value}}",
|
|
{ value: formatAuditValue(log.event_id) },
|
|
)}
|
|
</div>
|
|
<div>
|
|
{t(
|
|
"ui.common.audit.details.ip",
|
|
"IP · {{value}}",
|
|
{
|
|
value: formatAuditValue(log.ip_address),
|
|
},
|
|
)}
|
|
</div>
|
|
<div className="break-all">
|
|
{t(
|
|
"ui.common.audit.details.method",
|
|
"Method · {{value}}",
|
|
{ value: formatAuditValue(details.method) },
|
|
)}
|
|
</div>
|
|
<div className="break-all">
|
|
{t(
|
|
"ui.common.audit.details.path",
|
|
"Path · {{value}}",
|
|
{ value: formatAuditValue(details.path) },
|
|
)}
|
|
</div>
|
|
<div>
|
|
{t(
|
|
"ui.common.audit.details.latency",
|
|
"Latency · {{value}}",
|
|
{
|
|
value:
|
|
details.latency_ms !== undefined
|
|
? `${details.latency_ms}ms`
|
|
: "-",
|
|
},
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="uppercase tracking-[0.16em]">
|
|
{t("ui.common.audit.details.actor", "Actor")}
|
|
</div>
|
|
<div>
|
|
{t(
|
|
"ui.common.audit.details.actor_id",
|
|
"User ID · {{value}}",
|
|
{ value: actorLabel },
|
|
)}
|
|
</div>
|
|
<div>
|
|
{t(
|
|
"ui.common.audit.details.tenant",
|
|
"Tenant · {{value}}",
|
|
{
|
|
value: formatAuditValue(details.tenant_id),
|
|
},
|
|
)}
|
|
</div>
|
|
<div>
|
|
{t(
|
|
"ui.common.audit.details.device",
|
|
"Device · {{value}}",
|
|
{ value: formatAuditValue(log.device_id) },
|
|
)}
|
|
</div>
|
|
<div className="break-all">
|
|
{t(
|
|
"ui.common.audit.details.target",
|
|
"Client ID · {{value}}",
|
|
{ value: targetLabel },
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="uppercase tracking-[0.16em]">
|
|
{t("ui.common.audit.details.result", "Result")}
|
|
</div>
|
|
<div className="break-all">
|
|
{t(
|
|
"ui.common.audit.details.error",
|
|
"Error · {{value}}",
|
|
{ value: formatAuditValue(details.error) },
|
|
)}
|
|
</div>
|
|
<div className="break-all">
|
|
{t(
|
|
"ui.common.audit.details.before",
|
|
"Before · {{value}}",
|
|
{ value: formatAuditValue(details.before) },
|
|
)}
|
|
</div>
|
|
<div className="break-all">
|
|
{t(
|
|
"ui.common.audit.details.after",
|
|
"After · {{value}}",
|
|
{ value: formatAuditValue(details.after) },
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
{logs.length === 0 && !loading && (
|
|
<TableRow className={commonTableRowClass}>
|
|
<TableCell
|
|
colSpan={6}
|
|
className={cx(
|
|
commonTableCellClass,
|
|
"text-center text-muted-foreground py-8",
|
|
)}
|
|
>
|
|
{t("msg.common.audit.empty", "No audit logs found.")}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
<div className="p-4 border-t text-center flex-shrink-0 bg-background/50 backdrop-blur-sm z-10">
|
|
{hasNextPage ? (
|
|
<div className="flex flex-col items-center gap-2">
|
|
{isFetchingNextPage && (
|
|
<span className="text-xs text-muted-foreground animate-pulse">
|
|
{t("msg.common.loading", "Loading more...")}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className={getCommonButtonClasses({
|
|
variant: "outline",
|
|
size: "sm",
|
|
})}
|
|
onClick={onLoadMore}
|
|
disabled={isFetchingNextPage}
|
|
>
|
|
{isFetchingNextPage
|
|
? t("msg.common.loading", "Loading...")
|
|
: t("ui.common.audit.load_more", "Load more")}
|
|
</button>
|
|
</div>
|
|
) : logs.length > 0 ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
{t("msg.common.audit.end", "End of audit feed")}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Internal table components for cleaner implementation
|
|
function Table({
|
|
className,
|
|
children,
|
|
style,
|
|
}: {
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
style?: React.CSSProperties;
|
|
}) {
|
|
return (
|
|
<table className={className} style={style}>
|
|
{children}
|
|
</table>
|
|
);
|
|
}
|
|
|
|
function TableHeader({
|
|
className,
|
|
children,
|
|
}: {
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return <thead className={className}>{children}</thead>;
|
|
}
|
|
|
|
function TableBody({
|
|
className,
|
|
children,
|
|
}: {
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return <tbody className={className}>{children}</tbody>;
|
|
}
|
|
|
|
function TableRow({
|
|
className,
|
|
children,
|
|
}: {
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return <tr className={className}>{children}</tr>;
|
|
}
|
|
|
|
function TableHead({
|
|
className,
|
|
children,
|
|
}: {
|
|
className?: string;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
return <th className={className}>{children}</th>;
|
|
}
|
|
|
|
function TableCell({
|
|
className,
|
|
children,
|
|
colSpan,
|
|
}: {
|
|
className?: string;
|
|
children: React.ReactNode;
|
|
colSpan?: number;
|
|
}) {
|
|
return (
|
|
<td className={className} colSpan={colSpan}>
|
|
{children}
|
|
</td>
|
|
);
|
|
}
|