첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
311
baron-sso/devfront/src/features/audit/AuditLogsPage.tsx
Normal file
311
baron-sso/devfront/src/features/audit/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
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 { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard";
|
||||
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";
|
||||
import { resolveProfileRole } from "../../lib/role";
|
||||
import { fetchMe } from "../auth/authApi";
|
||||
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
|
||||
|
||||
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<string, unknown> | 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 (
|
||||
<div className="p-8 text-center">
|
||||
{t("ui.common.loading", "Loading...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasDeveloperAccess) {
|
||||
return (
|
||||
<DeveloperAccessRequestCard
|
||||
title={t("ui.common.audit.title", "Audit Logs")}
|
||||
isPending={isDeveloperRequestPending}
|
||||
canRequest={canRequestDeveloperAccess}
|
||||
pendingMessage={t(
|
||||
"msg.dev.dashboard.access_pending",
|
||||
"개발자 권한 신청을 검토 중입니다.",
|
||||
)}
|
||||
deniedMessage={t(
|
||||
"msg.dev.audit.access_denied",
|
||||
"감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
|
||||
)}
|
||||
pendingDetailMessage={t(
|
||||
"msg.dev.dashboard.access_pending_detail",
|
||||
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
|
||||
)}
|
||||
deniedDetailMessage={t(
|
||||
"msg.dev.audit.access_denied_detail",
|
||||
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
|
||||
)}
|
||||
actionLabel={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
|
||||
onAction={() => navigate("/developer-requests")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user