import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react"; import * as React from "react"; import { useAuth } from "react-oidc-context"; import { useNavigate } from "react-router-dom"; 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 { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard"; import { useDeveloperAccessGate } from "../developer-access/developerAccessGate"; 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"; import { resolveProfileRole } from "../../lib/role"; import { fetchMe } from "../auth/authApi"; 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 navigate = useNavigate(); const auth = useAuth(); const hasAccessToken = Boolean(auth.user?.access_token); const userProfile = auth.user?.profile as Record | undefined; const role = resolveProfileRole(userProfile); const tenantId = userProfile?.tenant_id as string | undefined; 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 { data: me, isLoading: isLoadingMe } = useQuery({ queryKey: ["userMe"], queryFn: fetchMe, enabled: hasAccessToken, }); const profileRole = me?.role?.trim() || role; const { hasDeveloperAccess, isDeveloperRequestPending, canRequestDeveloperAccess, isLoadingDeveloperAccessGate, } = useDeveloperAccessGate({ hasAccessToken, profileRole, tenantId, isLoadingIdentity: isLoadingMe, }); 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, enabled: hasDeveloperAccess, }); 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 (isLoadingDeveloperAccessGate) { return (
{t("ui.common.loading", "Loading...")}
); } if (!hasDeveloperAccess) { return ( navigate("/developer-requests")} /> ); } if (query.error) { const axiosError = query.error as AxiosError<{ error?: string }>; if (axiosError.response?.status === 403) { return ; } const errMsg = axiosError.response?.data?.error ?? (query.error as Error).message; return (
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", { error: errMsg, })}
); } return (
} title={t("ui.common.audit.title", "Audit Logs")} description={t( "msg.dev.audit.subtitle", "현재 앱 범위의 개발자 작업 이력을 조회합니다.", )} actions={ <> {t("msg.common.audit.registry.count", "총 {{count}}개 로그", { count: logs.length, })} } />
{t("ui.common.audit.registry.title", "Audit registry")} {t( "msg.dev.audit.registry_description", "최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.", )}
{ e.preventDefault(); query.refetch(); }} className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]" >
setSearchClientId(e.target.value)} placeholder={t( "ui.common.audit.filters.client_id", "Filter by Client ID", )} />
setSearchAction(e.target.value.toUpperCase()) } placeholder={t( "ui.common.audit.filters.action", "Filter by Action (e.g. ROTATE_SECRET)", )} /> } />
query.fetchNextPage()} />
); } export default AuditLogsPage;