diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx index a2f53e51..b28a124c 100644 --- a/devfront/src/app/routes.tsx +++ b/devfront/src/app/routes.tsx @@ -1,5 +1,6 @@ import { Navigate, createBrowserRouter } from "react-router-dom"; import AppLayout from "../components/layout/AppLayout"; +import AuditLogsPage from "../features/audit/AuditLogsPage"; import AuthCallbackPage from "../features/auth/AuthCallbackPage"; import AuthGuard from "../features/auth/AuthGuard"; import LoginPage from "../features/auth/LoginPage"; @@ -31,6 +32,7 @@ export const router = createBrowserRouter( { path: "clients/:id", element: }, { path: "clients/:id/consents", element: }, { path: "clients/:id/settings", element: }, + { path: "audit-logs", element: }, ], }, ], diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index c0324ac1..66b8d175 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -1,4 +1,11 @@ -import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react"; +import { + BadgeCheck, + LogOut, + Moon, + NotebookTabs, + ShieldHalf, + Sun, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; @@ -13,6 +20,12 @@ const navItems = [ to: "/clients", icon: ShieldHalf, }, + { + labelKey: "ui.dev.nav.audit_logs", + labelFallback: "Audit Logs", + to: "/audit-logs", + icon: NotebookTabs, + }, ]; function AppLayout() { diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx index ebaee008..d3a33d06 100644 --- a/devfront/src/features/audit/AuditLogsPage.tsx +++ b/devfront/src/features/audit/AuditLogsPage.tsx @@ -1,141 +1,415 @@ -import { Filter, ListChecks, Search, Terminal } from "lucide-react"; +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 { 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"; -const auditFilters = [ - "Actor role = admin", - "Action = client.rotate_secret", - "Tenant = selected header", -]; +type AuditDetails = { + request_id?: string; + method?: string; + path?: string; + tenant_id?: string; + action?: string; + target_id?: string; + before?: unknown; + after?: unknown; + error?: string; +}; -const auditRows = [ - { - action: "client.create", - tenant: "TENANT-12", - actor: "ops.jane@baron", - result: "ok", - ts: "2026-01-26 15:21 KST", - }, - { - action: "client.rotate_secret", - tenant: "TENANT-12", - actor: "ops.jane@baron", - result: "ok", - ts: "2026-01-26 15:22 KST", - }, - { - action: "audit.export", - tenant: "TENANT-07", - actor: "auditor.lee@baron", - result: "rate_limited", - ts: "2026-01-26 15:30 KST", - }, -]; +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"); + const [expandedRows, setExpandedRows] = React.useState< + Record + >({}); + + const query = useInfiniteQuery({ + queryKey: ["dev-audit-logs", searchClientId, searchAction, statusFilter], + queryFn: ({ pageParam }) => + fetchDevAuditLogs(50, pageParam, { + client_id: searchClientId.trim() || undefined, + action: searchAction.trim() || 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.isLoading) { + return ( +
+ {t("msg.dev.audit.loading", "Loading audit logs...")} +
+ ); + } + + if (query.error) { + const errMsg = + (query.error as AxiosError<{ error?: string }>).response?.data?.error ?? + (query.error as Error).message; + return ( +
+ {t("msg.dev.audit.load_error", "Error loading logs: {{error}}", { + error: errMsg, + })} +
+ ); + } + return ( -
-
-
-

- Audit stream -

-

- Observe admin actions per tenant -

-

- ClickHouse-backed feed. Filter by tenant, actor, action, and - rate-limit status. Enforce admin-only access under /admin. -

-
-
- - -
-
+
+ + +
+

+ {t("ui.dev.audit.registry.title", "Audit registry")} +

+ + {t("ui.dev.audit.title", "Audit Logs")} + + + {t( + "msg.dev.audit.subtitle", + "Shows DevFront activity history within current tenant/app scope.", + )} + +
+
+ + {t("msg.dev.audit.loaded_count", "Loaded {{count}} rows", { + count: logs.length, + })} + + + +
+
+ +
+
+ + setSearchClientId(e.target.value)} + placeholder={t( + "ui.dev.audit.filter.client_id", + "Filter by Client ID", + )} + /> +
+ setSearchAction(e.target.value.toUpperCase())} + placeholder={t( + "ui.dev.audit.filter.action", + "Filter by Action (e.g. ROTATE_SECRET)", + )} + /> + +
-
-
-
- - - Try: tenant:TENANT-12 action:client.* - -
-
- {auditFilters.map((filter) => ( - - - {filter} - - ))} -
-
- {auditRows.map((row) => ( -
-
{row.action}
-
{row.tenant}
-
{row.actor}
-
- + + + + {t("ui.dev.audit.table.time", "Time")} + + + {t("ui.dev.audit.table.actor", "Actor")} + + + {t("ui.dev.audit.table.action", "Action")} + + + {t("ui.dev.audit.table.target", "Target")} + + + {t("ui.dev.audit.table.status", "Status")} + + + + + + {logs.length === 0 && ( + + - {row.result} - - {row.ts} -
-
- ))} -
-
+ {t("msg.dev.audit.empty", "No audit logs found.")} + + + )} + {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 ( + + + + {formatDateTime(row.timestamp)} + + +
+ {row.user_id || "-"} + {row.user_id ? ( + + ) : null} +
+
+ {actionLabel} + +
+ {targetValue} + {targetValue !== "-" ? ( + + ) : null} +
+
+ + + {row.status} + + + + + +
+ {expanded ? ( + + +
+
+
+ Request ID: {formatValue(details.request_id)} +
+
Method: {formatValue(details.method)}
+
Path: {formatValue(details.path)}
+
+ Tenant: {formatValue(details.tenant_id)} +
+
+
+
Before: {formatValue(details.before)}
+
After: {formatValue(details.after)}
+
Error: {formatValue(details.error)}
+
+
+
+
+ ) : null} +
+ ); + })} + + -
-
-

- Guard rails -

-

Tenant admin only

-

- Enforce Tenant Admin middleware and admin session TTL before - surfacing any audit feed. Super Admin role can bypass tenant - filter when needed. -

-
-
-

- Export rules -

-

- Rate-limit sensitive exports -

-

- Keep export endpoints behind admin-only routes with ClickHouse - query limits. Log download attempts with IP, role, and tenant - scope. -

-
-
-
+ {query.hasNextPage ? ( +
+ +
+ ) : null} +
+
); } diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index c6f3d108..56cb764b 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, ChevronLeft, ChevronRight, + Download, Filter, Search, } from "lucide-react"; @@ -275,6 +276,7 @@ function ClientConsentsPage() { onClick={handleExportCSV} disabled={filteredRows.length === 0} > + {t("ui.dev.clients.consents.export_csv", "Export CSV")}
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 91bcd6cc..964d4d55 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -30,6 +30,7 @@ import { deleteClient, fetchClient, updateClient, + updateClientStatus, } from "../../lib/devApi"; import type { ClientStatus, @@ -63,6 +64,7 @@ function ClientGeneralPage() { const [logoUrl, setLogoUrl] = useState(""); const [clientType, setClientType] = useState("private"); const [status, setStatus] = useState("active"); + const [initialStatus, setInitialStatus] = useState("active"); const [redirectUris, setRedirectUris] = useState(""); const [scopes, setScopes] = useState(() => [ { @@ -91,6 +93,7 @@ function ClientGeneralPage() { setName(client.name || client.id); setClientType(client.type); setStatus(client.status); + setInitialStatus(client.status); const metadata = client.metadata ?? {}; if (typeof metadata.description === "string") @@ -158,7 +161,6 @@ function ClientGeneralPage() { const payload: ClientUpsertRequest = { name, type: clientType, - status, scopes: scopeNames, metadata: { description, @@ -169,6 +171,7 @@ function ClientGeneralPage() { // 생성 시에는 Redirect URIs를 포함해서 전송 if (isCreate) { + payload.status = status; payload.redirectUris = redirectUris .split(",") .map((uri) => uri.trim()) @@ -176,11 +179,19 @@ function ClientGeneralPage() { return createClient(payload); } - // 수정 시에는 Redirect URIs는 별도 탭에서 관리하므로 제외 (빈 배열이나 undefined로 보내지 않음) - return updateClient(clientId as string, payload); + // 수정 시에는 Redirect URIs는 별도 탭에서 관리하고, + // status는 전용 PATCH API로 처리해서 감사로그 액션을 분리한다. + const updated = await updateClient(clientId as string, payload); + if (status !== initialStatus) { + await updateClientStatus(clientId as string, status); + } + return updated; }, onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ["clients"] }); + if (status !== initialStatus) { + setInitialStatus(status); + } if (result?.client?.id) { navigate(`/clients/${result.client.id}/settings`); } diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index b20e8cd0..1e124ec1 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -20,6 +20,25 @@ export type ClientListResponse = { offset: number; }; +export type DevAuditLog = { + event_id: string; + timestamp: string; + user_id: string; + event_type: string; + status: string; + ip_address: string; + user_agent: string; + device_id?: string; + details?: string; +}; + +export type DevAuditLogListResponse = { + items: DevAuditLog[]; + limit: number; + cursor?: string; + next_cursor?: string; +}; + export type ClientEndpoints = { discovery: string; issuer: string; @@ -210,3 +229,29 @@ export async function updateIdpConfig( export async function deleteIdpConfig(clientId: string, idpId: string) { await apiClient.delete(`/dev/clients/${clientId}/idps/${idpId}`); } + +export async function fetchDevAuditLogs( + limit = 50, + cursor?: string, + filters?: { + action?: string; + client_id?: string; + status?: string; + tenant_id?: string; + }, +) { + const { data } = await apiClient.get( + "/dev/audit-logs", + { + params: { + limit, + cursor, + action: filters?.action, + client_id: filters?.client_id, + status: filters?.status, + tenant_id: filters?.tenant_id, + }, + }, + ); + return data; +}