1
0
forked from baron/baron-sso
Files
baron-sso/devfront/src/features/audit/AuditLogsPage.tsx
2026-05-20 13:41:15 +09:00

244 lines
7.9 KiB
TypeScript

import { useInfiniteQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
import * as React from "react";
import { parseAuditDetails } from "../../../../common/core/audit";
import { AuditLogTable } from "../../../../common/core/components/audit";
import { PageHeader } from "../../../../common/core/components/page";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
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 type { DevAuditLog } from "../../lib/devApi";
import { fetchDevAuditLogs } from "../../lib/devApi";
import { t } from "../../lib/i18n";
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 = parseAuditDetails(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 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 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.common.audit.load_error", "Error loading logs: {{error}}", {
error: errMsg,
})}
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
icon={<NotebookTabs size={20} />}
title={t("ui.common.audit.title", "Audit Logs")}
description={t(
"msg.dev.audit.subtitle",
"현재 앱 범위의 개발자 작업 이력을 조회합니다.",
)}
actions={
<>
<Badge variant="muted">
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
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.common.export_csv", "CSV 내보내기")}
</Button>
</>
}
/>
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>
{t("ui.common.audit.registry.title", "Audit registry")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.audit.registry_description",
"최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<SearchFilterBar
primary={
<form
onSubmit={(e) => {
e.preventDefault();
query.refetch();
}}
className="grid flex-1 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.common.audit.filters.client_id",
"Filter by Client ID",
)}
/>
</div>
<Input
value={searchAction}
onChange={(e) =>
setSearchAction(e.target.value.toUpperCase())
}
placeholder={t(
"ui.common.audit.filters.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.common.audit.filters.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"
: ""
}
>
<AuditLogTable
logs={logs}
t={t}
loading={query.isLoading}
hasNextPage={Boolean(query.hasNextPage)}
isFetchingNextPage={query.isFetchingNextPage}
onLoadMore={() => query.fetchNextPage()}
/>
</div>
</CardContent>
</Card>
</div>
);
}
export default AuditLogsPage;