1
0
forked from baron/baron-sso

감사로그 화면 연동과 상태변경 로그 분리 및 CSV/UI 개선

This commit is contained in:
2026-02-27 17:51:14 +09:00
parent 914b1b0d49
commit 8db37c377a
6 changed files with 481 additions and 134 deletions

View File

@@ -1,5 +1,6 @@
import { Navigate, createBrowserRouter } from "react-router-dom"; import { Navigate, createBrowserRouter } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout"; import AppLayout from "../components/layout/AppLayout";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage"; import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthGuard from "../features/auth/AuthGuard"; import AuthGuard from "../features/auth/AuthGuard";
import LoginPage from "../features/auth/LoginPage"; import LoginPage from "../features/auth/LoginPage";
@@ -31,6 +32,7 @@ export const router = createBrowserRouter(
{ path: "clients/:id", element: <ClientDetailsPage /> }, { path: "clients/:id", element: <ClientDetailsPage /> },
{ path: "clients/:id/consents", element: <ClientConsentsPage /> }, { path: "clients/:id/consents", element: <ClientConsentsPage /> },
{ path: "clients/:id/settings", element: <ClientGeneralPage /> }, { path: "clients/:id/settings", element: <ClientGeneralPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
], ],
}, },
], ],

View File

@@ -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 { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
@@ -13,6 +20,12 @@ const navItems = [
to: "/clients", to: "/clients",
icon: ShieldHalf, icon: ShieldHalf,
}, },
{
labelKey: "ui.dev.nav.audit_logs",
labelFallback: "Audit Logs",
to: "/audit-logs",
icon: NotebookTabs,
},
]; ];
function AppLayout() { function AppLayout() {

View File

@@ -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 = [ type AuditDetails = {
"Actor role = admin", request_id?: string;
"Action = client.rotate_secret", method?: string;
"Tenant = selected header", path?: string;
]; tenant_id?: string;
action?: string;
target_id?: string;
before?: unknown;
after?: unknown;
error?: string;
};
const auditRows = [ function parseDetails(details?: string): AuditDetails {
{ if (!details) {
action: "client.create", return {};
tenant: "TENANT-12", }
actor: "ops.jane@baron", try {
result: "ok", const parsed = JSON.parse(details);
ts: "2026-01-26 15:21 KST", if (parsed && typeof parsed === "object") {
}, return parsed as AuditDetails;
{ }
action: "client.rotate_secret", } catch {}
tenant: "TENANT-12", return {};
actor: "ops.jane@baron", }
result: "ok",
ts: "2026-01-26 15:22 KST", function formatValue(value: unknown): string {
}, if (value === null || value === undefined || value === "") {
{ return "-";
action: "audit.export", }
tenant: "TENANT-07", if (typeof value === "string") {
actor: "auditor.lee@baron", return value;
result: "rate_limited", }
ts: "2026-01-26 15:30 KST", 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() { function AuditLogsPage() {
const [searchClientId, setSearchClientId] = React.useState("");
const [searchAction, setSearchAction] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const [expandedRows, setExpandedRows] = React.useState<
Record<string, boolean>
>({});
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 (
<div className="p-8 text-center">
{t("msg.dev.audit.loading", "Loading audit logs...")}
</div>
);
}
if (query.error) {
const errMsg =
(query.error as AxiosError<{ error?: string }>).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 ( return (
<div className="space-y-8"> <div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <Card className="glass-panel">
<div> <CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]"> <div>
Audit stream <p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
</p> {t("ui.dev.audit.registry.title", "Audit registry")}
<h2 className="text-2xl font-semibold"> </p>
Observe admin actions per tenant <CardTitle className="text-3xl font-black tracking-tight">
</h2> {t("ui.dev.audit.title", "Audit Logs")}
<p className="text-sm text-[var(--color-muted)]"> </CardTitle>
ClickHouse-backed feed. Filter by tenant, actor, action, and <CardDescription>
rate-limit status. Enforce admin-only access under /admin. {t(
</p> "msg.dev.audit.subtitle",
</div> "Shows DevFront activity history within current tenant/app scope.",
<div className="flex items-center gap-2"> )}
<button </CardDescription>
type="button" </div>
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]" <div className="flex items-center gap-2">
> <Badge variant="muted">
<Filter size={14} /> {t("msg.dev.audit.loaded_count", "Loaded {{count}} rows", {
Saved filters count: logs.length,
</button> })}
<button </Badge>
type="button" <Button
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black" variant="outline"
> onClick={() => query.refetch()}
<ListChecks size={14} /> disabled={query.isFetching}
Export CSV >
</button> <RefreshCw size={16} />
</div> {t("ui.common.refresh", "새로고침")}
</div> </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">
<div 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>
</div>
<div className="grid gap-4 md:grid-cols-[1.1fr,0.9fr]"> <Table className="table-fixed">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"> <TableHeader>
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-3 py-2 text-[var(--color-muted)]"> <TableRow>
<Search size={14} /> <TableHead className="w-[190px]">
<span className="text-sm"> {t("ui.dev.audit.table.time", "Time")}
Try: tenant:TENANT-12 action:client.* </TableHead>
</span> <TableHead className="w-[180px]">
</div> {t("ui.dev.audit.table.actor", "Actor")}
<div className="mt-4 space-y-3"> </TableHead>
{auditFilters.map((filter) => ( <TableHead className="w-[180px]">
<span {t("ui.dev.audit.table.action", "Action")}
key={filter} </TableHead>
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs text-[var(--color-muted)]" <TableHead className="w-[260px]">
> {t("ui.dev.audit.table.target", "Target")}
<Terminal size={12} /> </TableHead>
{filter} <TableHead className="w-[120px]">
</span> {t("ui.dev.audit.table.status", "Status")}
))} </TableHead>
</div> <TableHead className="w-[80px]" />
<div className="mt-5 divide-y divide-[var(--color-border)]"> </TableRow>
{auditRows.map((row) => ( </TableHeader>
<div <TableBody>
key={`${row.action}-${row.ts}`} {logs.length === 0 && (
className="grid grid-cols-[1.2fr,1fr,1fr,1fr] items-center gap-2 py-3 text-sm" <TableRow>
> <TableCell
<div className="font-semibold">{row.action}</div> colSpan={6}
<div className="text-[var(--color-muted)]">{row.tenant}</div> className="text-center text-muted-foreground"
<div className="text-[var(--color-muted)]">{row.actor}</div>
<div className="inline-flex items-center gap-2">
<span
className={`rounded-full px-2 py-1 text-xs ${
row.result === "ok"
? "bg-[rgba(54,211,153,0.16)] text-[var(--color-accent)]"
: "bg-[rgba(249,168,38,0.16)] text-[var(--color-accent-strong)]"
}`}
> >
{row.result} {t("msg.dev.audit.empty", "No audit logs found.")}
</span> </TableCell>
<span className="text-[var(--color-muted)]">{row.ts}</span> </TableRow>
</div> )}
</div> {logs.map((row, index) => {
))} const details = parseDetails(row.details);
</div> const actionLabel = details.action || row.event_type;
</div> 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 className="space-y-4"> {query.hasNextPage ? (
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"> <div className="flex justify-center">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]"> <Button
Guard rails variant="outline"
</p> onClick={() => query.fetchNextPage()}
<h3 className="mt-1 text-lg font-semibold">Tenant admin only</h3> disabled={query.isFetchingNextPage}
<p className="text-sm text-[var(--color-muted)]"> >
Enforce Tenant Admin middleware and admin session TTL before {query.isFetchingNextPage
surfacing any audit feed. Super Admin role can bypass tenant ? t("msg.common.loading", "Loading...")
filter when needed. : t("ui.dev.audit.load_more", "Load more")}
</p> </Button>
</div> </div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"> ) : null}
<p className="text-xs uppercase tracking-[0.18em] text-[var(--color-muted)]"> </CardContent>
Export rules </Card>
</p>
<h3 className="mt-1 text-lg font-semibold">
Rate-limit sensitive exports
</h3>
<p className="text-sm text-[var(--color-muted)]">
Keep export endpoints behind admin-only routes with ClickHouse
query limits. Log download attempts with IP, role, and tenant
scope.
</p>
</div>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -3,6 +3,7 @@ import {
ArrowLeft, ArrowLeft,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Download,
Filter, Filter,
Search, Search,
} from "lucide-react"; } from "lucide-react";
@@ -275,6 +276,7 @@ function ClientConsentsPage() {
onClick={handleExportCSV} onClick={handleExportCSV}
disabled={filteredRows.length === 0} disabled={filteredRows.length === 0}
> >
<Download className="h-4 w-4" />
{t("ui.dev.clients.consents.export_csv", "Export CSV")} {t("ui.dev.clients.consents.export_csv", "Export CSV")}
</Button> </Button>
</div> </div>

View File

@@ -30,6 +30,7 @@ import {
deleteClient, deleteClient,
fetchClient, fetchClient,
updateClient, updateClient,
updateClientStatus,
} from "../../lib/devApi"; } from "../../lib/devApi";
import type { import type {
ClientStatus, ClientStatus,
@@ -63,6 +64,7 @@ function ClientGeneralPage() {
const [logoUrl, setLogoUrl] = useState(""); const [logoUrl, setLogoUrl] = useState("");
const [clientType, setClientType] = useState<ClientType>("private"); const [clientType, setClientType] = useState<ClientType>("private");
const [status, setStatus] = useState<ClientStatus>("active"); const [status, setStatus] = useState<ClientStatus>("active");
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
const [redirectUris, setRedirectUris] = useState(""); const [redirectUris, setRedirectUris] = useState("");
const [scopes, setScopes] = useState<ScopeItem[]>(() => [ const [scopes, setScopes] = useState<ScopeItem[]>(() => [
{ {
@@ -91,6 +93,7 @@ function ClientGeneralPage() {
setName(client.name || client.id); setName(client.name || client.id);
setClientType(client.type); setClientType(client.type);
setStatus(client.status); setStatus(client.status);
setInitialStatus(client.status);
const metadata = client.metadata ?? {}; const metadata = client.metadata ?? {};
if (typeof metadata.description === "string") if (typeof metadata.description === "string")
@@ -158,7 +161,6 @@ function ClientGeneralPage() {
const payload: ClientUpsertRequest = { const payload: ClientUpsertRequest = {
name, name,
type: clientType, type: clientType,
status,
scopes: scopeNames, scopes: scopeNames,
metadata: { metadata: {
description, description,
@@ -169,6 +171,7 @@ function ClientGeneralPage() {
// 생성 시에는 Redirect URIs를 포함해서 전송 // 생성 시에는 Redirect URIs를 포함해서 전송
if (isCreate) { if (isCreate) {
payload.status = status;
payload.redirectUris = redirectUris payload.redirectUris = redirectUris
.split(",") .split(",")
.map((uri) => uri.trim()) .map((uri) => uri.trim())
@@ -176,11 +179,19 @@ function ClientGeneralPage() {
return createClient(payload); return createClient(payload);
} }
// 수정 시에는 Redirect URIs는 별도 탭에서 관리하므로 제외 (빈 배열이나 undefined로 보내지 않음) // 수정 시에는 Redirect URIs는 별도 탭에서 관리하고,
return updateClient(clientId as string, payload); // status는 전용 PATCH API로 처리해서 감사로그 액션을 분리한다.
const updated = await updateClient(clientId as string, payload);
if (status !== initialStatus) {
await updateClientStatus(clientId as string, status);
}
return updated;
}, },
onSuccess: (result) => { onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ["clients"] }); queryClient.invalidateQueries({ queryKey: ["clients"] });
if (status !== initialStatus) {
setInitialStatus(status);
}
if (result?.client?.id) { if (result?.client?.id) {
navigate(`/clients/${result.client.id}/settings`); navigate(`/clients/${result.client.id}/settings`);
} }

View File

@@ -20,6 +20,25 @@ export type ClientListResponse = {
offset: number; 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 = { export type ClientEndpoints = {
discovery: string; discovery: string;
issuer: string; issuer: string;
@@ -210,3 +229,29 @@ export async function updateIdpConfig(
export async function deleteIdpConfig(clientId: string, idpId: string) { export async function deleteIdpConfig(clientId: string, idpId: string) {
await apiClient.delete(`/dev/clients/${clientId}/idps/${idpId}`); 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<DevAuditLogListResponse>(
"/dev/audit-logs",
{
params: {
limit,
cursor,
action: filters?.action,
client_id: filters?.client_id,
status: filters?.status,
tenant_id: filters?.tenant_id,
},
},
);
return data;
}