forked from baron/baron-sso
- Centralized biome.json, tailwind.config.ts, and vite.config.ts into common/config. - Updated sub-apps to inherit from shared base configurations. - Deduplicated dependencies across apps using common workspace. - Fixed TypeScript resolution issues by restoring necessary build dependencies. - Removed obsolete package-lock.json files. - Applied minor import fixes via Biome. - Fixed react-router-dom v7 type errors.
474 lines
17 KiB
TypeScript
474 lines
17 KiB
TypeScript
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import {
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Copy,
|
|
Download,
|
|
RefreshCw,
|
|
Search,
|
|
} from "lucide-react";
|
|
import * as React from "react";
|
|
import {
|
|
commonTableShellClass,
|
|
commonTableViewportClass,
|
|
} from "../../../../common/ui/table";
|
|
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
|
|
import { Badge } from "../../components/ui/badge";
|
|
import { Button } from "../../components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "../../components/ui/card";
|
|
import { Input } from "../../components/ui/input";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "../../components/ui/table";
|
|
import type { DevAuditLog } from "../../lib/devApi";
|
|
import { fetchDevAuditLogs } from "../../lib/devApi";
|
|
import { t } from "../../lib/i18n";
|
|
|
|
type AuditDetails = {
|
|
request_id?: string;
|
|
method?: string;
|
|
path?: string;
|
|
tenant_id?: string;
|
|
action?: string;
|
|
target_id?: string;
|
|
before?: unknown;
|
|
after?: unknown;
|
|
error?: string;
|
|
};
|
|
|
|
function parseDetails(details?: string): AuditDetails {
|
|
if (!details) {
|
|
return {};
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(details);
|
|
if (parsed && typeof parsed === "object") {
|
|
return parsed as AuditDetails;
|
|
}
|
|
} catch {}
|
|
return {};
|
|
}
|
|
|
|
function formatValue(value: unknown): string {
|
|
if (value === null || value === undefined || value === "") {
|
|
return "-";
|
|
}
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function formatDateTime(value: string): string {
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return value;
|
|
}
|
|
return parsed.toLocaleString("ko-KR");
|
|
}
|
|
|
|
function toCsv(logs: DevAuditLog[]) {
|
|
const header = [
|
|
"timestamp",
|
|
"user_id",
|
|
"status",
|
|
"event_type",
|
|
"action",
|
|
"target_id",
|
|
"tenant_id",
|
|
"request_id",
|
|
];
|
|
const rows = logs.map((logItem) => {
|
|
const details = parseDetails(logItem.details);
|
|
return [
|
|
logItem.timestamp,
|
|
logItem.user_id || "",
|
|
logItem.status,
|
|
logItem.event_type,
|
|
details.action || "",
|
|
details.target_id || "",
|
|
details.tenant_id || "",
|
|
details.request_id || "",
|
|
];
|
|
});
|
|
return [header, ...rows]
|
|
.map((line) =>
|
|
line.map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(","),
|
|
)
|
|
.join("\n");
|
|
}
|
|
|
|
function downloadCsv(content: string, filename: string) {
|
|
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = filename;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
document.body.removeChild(anchor);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function AuditLogsPage() {
|
|
const [searchClientId, setSearchClientId] = React.useState("");
|
|
const [searchAction, setSearchAction] = React.useState("");
|
|
const [statusFilter, setStatusFilter] = React.useState("all");
|
|
|
|
// Use deferred values to avoid UI lag during rapid typing
|
|
const deferredSearchClientId = React.useDeferredValue(searchClientId.trim());
|
|
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
|
|
|
|
const [expandedRows, setExpandedRows] = React.useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
|
|
const query = useInfiniteQuery({
|
|
queryKey: [
|
|
"dev-audit-logs",
|
|
deferredSearchClientId,
|
|
deferredSearchAction,
|
|
statusFilter,
|
|
],
|
|
queryFn: ({ pageParam }) =>
|
|
fetchDevAuditLogs(50, pageParam, {
|
|
client_id: deferredSearchClientId || undefined,
|
|
action: deferredSearchAction || undefined,
|
|
status: statusFilter !== "all" ? statusFilter : undefined,
|
|
}),
|
|
initialPageParam: undefined as string | undefined,
|
|
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
|
});
|
|
|
|
const logs =
|
|
query.data?.pages.flatMap((page) =>
|
|
page.items.filter((item): item is DevAuditLog => Boolean(item)),
|
|
) ?? [];
|
|
|
|
const handleCopy = (value: string) => {
|
|
if (!value) {
|
|
return;
|
|
}
|
|
navigator.clipboard.writeText(value);
|
|
};
|
|
|
|
const handleExportCsv = () => {
|
|
const csv = toCsv(logs);
|
|
const stamp = new Date().toISOString().replaceAll(":", "-");
|
|
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
|
|
};
|
|
|
|
if (query.error) {
|
|
const axiosError = query.error as AxiosError<{ error?: string }>;
|
|
if (axiosError.response?.status === 403) {
|
|
return <ForbiddenMessage resourceToken="audit" />;
|
|
}
|
|
|
|
const errMsg =
|
|
axiosError.response?.data?.error ?? (query.error as Error).message;
|
|
return (
|
|
<div className="p-8 text-center text-red-500">
|
|
{t("msg.dev.audit.load_error", "Error loading logs: {{error}}", {
|
|
error: errMsg,
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card className="glass-panel">
|
|
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
|
{t("ui.dev.audit.registry.title", "Audit registry")}
|
|
</p>
|
|
<CardTitle className="text-3xl font-black tracking-tight">
|
|
{t("ui.dev.audit.title", "Audit Logs")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.dev.audit.subtitle",
|
|
"Shows DevFront activity history within current tenant/app scope.",
|
|
)}
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="muted">
|
|
{t("msg.dev.audit.loaded_count", "Loaded {{count}} rows", {
|
|
count: logs.length,
|
|
})}
|
|
</Badge>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => query.refetch()}
|
|
disabled={query.isFetching}
|
|
>
|
|
<RefreshCw size={16} />
|
|
{t("ui.common.refresh", "새로고침")}
|
|
</Button>
|
|
<Button
|
|
className="shadow-sm shadow-primary/30"
|
|
onClick={handleExportCsv}
|
|
>
|
|
<Download size={16} />
|
|
{t("ui.dev.clients.consents.export_csv", "Export CSV")}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
query.refetch();
|
|
}}
|
|
className="grid gap-2 md:grid-cols-[1fr,1fr,180px]"
|
|
>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
className="pl-10"
|
|
value={searchClientId}
|
|
onChange={(e) => setSearchClientId(e.target.value)}
|
|
placeholder={t(
|
|
"ui.dev.audit.filter.client_id",
|
|
"Filter by Client ID",
|
|
)}
|
|
/>
|
|
</div>
|
|
<Input
|
|
value={searchAction}
|
|
onChange={(e) => setSearchAction(e.target.value.toUpperCase())}
|
|
placeholder={t(
|
|
"ui.dev.audit.filter.action",
|
|
"Filter by Action (e.g. ROTATE_SECRET)",
|
|
)}
|
|
/>
|
|
<select
|
|
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
>
|
|
<option value="all">
|
|
{t("ui.dev.audit.filter.status_all", "All Status")}
|
|
</option>
|
|
<option value="success">
|
|
{t("ui.common.status.success", "Success")}
|
|
</option>
|
|
<option value="failure">
|
|
{t("ui.common.status.failure", "Failure")}
|
|
</option>
|
|
</select>
|
|
</form>
|
|
|
|
<div
|
|
className={
|
|
query.isFetching && !query.isFetchingNextPage
|
|
? "opacity-50 transition-opacity"
|
|
: ""
|
|
}
|
|
>
|
|
<div className={commonTableShellClass}>
|
|
<div className={commonTableViewportClass}>
|
|
<Table className="table-fixed">
|
|
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
|
<TableRow>
|
|
<TableHead className="w-[190px]">
|
|
{t("ui.dev.audit.table.time", "Time")}
|
|
</TableHead>
|
|
<TableHead className="w-[180px]">
|
|
{t("ui.dev.audit.table.actor", "Actor")}
|
|
</TableHead>
|
|
<TableHead className="w-[180px]">
|
|
{t("ui.dev.audit.table.action", "Action")}
|
|
</TableHead>
|
|
<TableHead className="w-[260px]">
|
|
{t("ui.dev.audit.table.target", "Target")}
|
|
</TableHead>
|
|
<TableHead className="w-[120px]">
|
|
{t("ui.dev.audit.table.status", "Status")}
|
|
</TableHead>
|
|
<TableHead className="w-[80px]" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{query.isLoading && logs.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={6}
|
|
className="py-8 text-center text-muted-foreground"
|
|
>
|
|
{t("msg.dev.audit.loading", "Loading audit logs...")}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : logs.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={6}
|
|
className="text-center text-muted-foreground"
|
|
>
|
|
{t("msg.dev.audit.empty", "No audit logs found.")}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
logs.map((row, index) => {
|
|
const details = parseDetails(row.details);
|
|
const actionLabel = details.action || row.event_type;
|
|
const targetValue = details.target_id || "-";
|
|
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
|
|
const expanded = Boolean(expandedRows[rowKey]);
|
|
return (
|
|
<React.Fragment key={rowKey}>
|
|
<TableRow>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{formatDateTime(row.timestamp)}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<span>{row.user_id || "-"}</span>
|
|
{row.user_id ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground"
|
|
onClick={() => handleCopy(row.user_id)}
|
|
>
|
|
<Copy className="h-3 w-3" />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
{actionLabel}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<span className="break-all">
|
|
{targetValue}
|
|
</span>
|
|
{targetValue !== "-" ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-muted-foreground"
|
|
onClick={() => handleCopy(targetValue)}
|
|
>
|
|
<Copy className="h-3 w-3" />
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={
|
|
row.status === "success"
|
|
? "success"
|
|
: "warning"
|
|
}
|
|
>
|
|
{row.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Button
|
|
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="bg-card/20">
|
|
<TableCell
|
|
colSpan={6}
|
|
className="text-xs text-muted-foreground"
|
|
>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<div className="space-y-1">
|
|
<div>
|
|
Request ID:{" "}
|
|
{formatValue(details.request_id)}
|
|
</div>
|
|
<div>
|
|
Method: {formatValue(details.method)}
|
|
</div>
|
|
<div>
|
|
Path: {formatValue(details.path)}
|
|
</div>
|
|
<div>
|
|
Tenant: {formatValue(details.tenant_id)}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1 break-all">
|
|
<div>
|
|
Before: {formatValue(details.before)}
|
|
</div>
|
|
<div>
|
|
After: {formatValue(details.after)}
|
|
</div>
|
|
<div>
|
|
Error: {formatValue(details.error)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : null}
|
|
</React.Fragment>
|
|
);
|
|
})
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{query.hasNextPage ? (
|
|
<div className="flex justify-center">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => query.fetchNextPage()}
|
|
disabled={query.isFetchingNextPage}
|
|
>
|
|
{query.isFetchingNextPage
|
|
? t("msg.common.loading", "Loading...")
|
|
: t("ui.dev.audit.load_more", "Load more")}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default AuditLogsPage;
|