- MDI: additive tab layer over existing routing (MdiTabBar + mdiStore, sessionStorage, dedup, max 10, keyboard a11y, dark-theme tokens); route tree untouched - tenant p2: JWT tid claim with kintex fallback (backward compatible), EventAccessGuard cross-tenant 403 (EventTenantMapper + isolation tests), explicit tenant scoping on analytics/admin/sysuser aggregates; login threads app_user.tenant_id into tokens - i18n sweep 2: 17 core+admin screens extracted to 30 namespaces x 4 locales (ko/en/zh/ja), zero ko-render regression; P2/P3 sweep list documented - verified: tsc -b --force, vite build, compileJava, test (75/75) all EXIT 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
/*
|
||
* SCR-A5 감사로그 (M18 · §5B-1). Stitch scr_a5 이식 + 라이브 백엔드 실배선.
|
||
* 정본: GET /api/admin/audit?action&actorId&eventId&page&size → PageResponse<AuditLogRow> (AuditLogController).
|
||
* 보안: 백엔드 DTO에 IP/자격증명 원문 미포함 → IP 컬럼 미표시(포렌식·PII 무결성). summary 는 백엔드가 정제(민감정보 제거).
|
||
* 상태 3종: 로딩 스켈레톤 / 빈 / 에러(재시도). 포렌식 무결성 원칙상 샘플 폴백을 두지 않는다.
|
||
* CSV 내보내기: 현재 페이지 로드분만 클라이언트 생성(서버측 전량 export 엔드포인트는 미제공 — 갭).
|
||
*/
|
||
import { useState } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Trans, useTranslation } from 'react-i18next';
|
||
import type { TFunction } from 'i18next';
|
||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||
import { Button } from '../../components/ui/Button';
|
||
import { IconDownload, IconSearch } from '../../components/ui/icons';
|
||
import { auditApi, KNOWN_AUDIT_ACTIONS, type AuditLogRow } from './adminModulesApi';
|
||
import './admin-modules.css';
|
||
|
||
const PAGE_SIZES = [20, 50, 100];
|
||
|
||
export function AuditLogPage() {
|
||
const { t } = useTranslation();
|
||
const [action, setAction] = useState('');
|
||
const [actorId, setActorId] = useState('');
|
||
const [eventId, setEventId] = useState('');
|
||
const [size, setSize] = useState(50);
|
||
const [page, setPage] = useState(0);
|
||
// 실제 조회에 반영된 확정 필터(입력과 분리 — "필터 적용" 클릭 시 반영)
|
||
const [applied, setApplied] = useState<{ action: string; actorId: string; eventId: string }>({
|
||
action: '',
|
||
actorId: '',
|
||
eventId: '',
|
||
});
|
||
|
||
const q = useQuery({
|
||
queryKey: ['admin-audit', applied, page, size],
|
||
queryFn: () =>
|
||
auditApi.search({
|
||
action: applied.action || undefined,
|
||
actorId: applied.actorId || undefined,
|
||
eventId: applied.eventId || undefined,
|
||
page,
|
||
size,
|
||
}),
|
||
retry: false,
|
||
});
|
||
|
||
const rows = q.data?.items ?? [];
|
||
const total = q.data?.total ?? 0;
|
||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||
const from = total === 0 ? 0 : page * size + 1;
|
||
const to = Math.min(total, (page + 1) * size);
|
||
|
||
function applyFilters() {
|
||
setPage(0);
|
||
setApplied({ action, actorId, eventId });
|
||
}
|
||
function resetFilters() {
|
||
setAction('');
|
||
setActorId('');
|
||
setEventId('');
|
||
setPage(0);
|
||
setApplied({ action: '', actorId: '', eventId: '' });
|
||
}
|
||
|
||
return (
|
||
<div className="kx-page kx-adm">
|
||
<header className="kx-adm__head">
|
||
<div>
|
||
<h1 className="kx-adm__title">{t('audit.title')}</h1>
|
||
<p className="kx-adm__subtitle">
|
||
{t('audit.subtitle')}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="secondary"
|
||
leadingIcon={<IconDownload size={16} />}
|
||
onClick={() => exportCsv(t, rows)}
|
||
disabled={rows.length === 0}
|
||
>
|
||
{t('audit.exportCsv')}
|
||
</Button>
|
||
</header>
|
||
|
||
{/* 필터 바 */}
|
||
<section className="kx-card kx-adm-filter" aria-label={t('audit.filterAria')}>
|
||
<div className="kx-adm-filter__grid">
|
||
<label className="kx-adm-field">
|
||
<span className="kx-adm-field__label">{t('audit.actionType')}</span>
|
||
<input
|
||
className="kx-adm-field__input"
|
||
list="audit-actions"
|
||
placeholder={t('audit.allActions')}
|
||
value={action}
|
||
onChange={(e) => setAction(e.target.value)}
|
||
/>
|
||
<datalist id="audit-actions">
|
||
{KNOWN_AUDIT_ACTIONS.map((a) => (
|
||
<option key={a} value={a} />
|
||
))}
|
||
</datalist>
|
||
</label>
|
||
<label className="kx-adm-field">
|
||
<span className="kx-adm-field__label">{t('audit.actorId')}</span>
|
||
<input
|
||
className="kx-adm-field__input"
|
||
placeholder={t('audit.actorIdPh')}
|
||
value={actorId}
|
||
onChange={(e) => setActorId(e.target.value)}
|
||
/>
|
||
</label>
|
||
<label className="kx-adm-field">
|
||
<span className="kx-adm-field__label">{t('audit.eventId')}</span>
|
||
<input
|
||
className="kx-adm-field__input"
|
||
placeholder={t('audit.eventIdPh')}
|
||
value={eventId}
|
||
onChange={(e) => setEventId(e.target.value)}
|
||
/>
|
||
</label>
|
||
<div className="kx-adm-filter__actions">
|
||
<Button variant="ghost" onClick={resetFilters}>
|
||
{t('audit.reset')}
|
||
</Button>
|
||
<Button leadingIcon={<IconSearch size={16} />} onClick={applyFilters}>
|
||
{t('audit.applyFilter')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 본문 */}
|
||
{q.isLoading && <TableSkeleton />}
|
||
|
||
{q.isError && !q.isLoading && (
|
||
<ErrorState message={t('audit.loadError')} onRetry={() => q.refetch()} />
|
||
)}
|
||
|
||
{!q.isLoading && !q.isError && (
|
||
<section className="kx-card kx-adm-table-card" aria-label={t('audit.tableAria')}>
|
||
{rows.length === 0 ? (
|
||
<EmptyState
|
||
title={t('audit.emptyTitle')}
|
||
description={t('audit.emptyDesc')}
|
||
/>
|
||
) : (
|
||
<>
|
||
<div className="kx-table-scroll">
|
||
<table className="kx-table kx-table--zebra kx-adm-audit">
|
||
<thead>
|
||
<tr>
|
||
<th>{t('audit.colTime')}</th>
|
||
<th>{t('audit.colUser')}</th>
|
||
<th>{t('audit.colAction')}</th>
|
||
<th>{t('audit.colTarget')}</th>
|
||
<th>{t('audit.colEvent')}</th>
|
||
<th>{t('audit.colResult')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={r.id}>
|
||
<td className="kx-adm-mono">{fmtTs(r.createdAt)}</td>
|
||
<td>
|
||
<div className="kx-adm-actor">
|
||
<span className="kx-adm-actor__avatar" aria-hidden="true">
|
||
{initial(r.actorName ?? r.actorId)}
|
||
</span>
|
||
<span className="kx-adm-actor__body">
|
||
<span className="kx-adm-actor__name">{r.actorName ?? '—'}</span>
|
||
{r.actorId && (
|
||
<span className="kx-adm-actor__id">{r.actorId}</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span className="kx-adm-action">{r.action ?? '—'}</span>
|
||
{r.summary && <span className="kx-adm-summary">{r.summary}</span>}
|
||
</td>
|
||
<td>
|
||
{r.targetType ? (
|
||
<span className="kx-adm-target">
|
||
<span className="kx-adm-target__type">{r.targetType}</span>
|
||
{r.targetId && (
|
||
<span className="kx-adm-target__id">#{r.targetId}</span>
|
||
)}
|
||
</span>
|
||
) : (
|
||
<span className="kx-adm-muted">—</span>
|
||
)}
|
||
</td>
|
||
<td className="kx-adm-muted">{r.eventId ?? '—'}</td>
|
||
<td>
|
||
<ResultPill value={r.result} />
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="kx-adm-pager">
|
||
<p className="kx-adm-pager__info">
|
||
<Trans i18nKey="audit.showing" values={{ from, to, total: total.toLocaleString() }} components={[<strong className="tnum" key="a" />, <strong className="tnum" key="b" />]} />
|
||
</p>
|
||
<div className="kx-adm-pager__nav">
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||
disabled={page === 0}
|
||
>
|
||
{t('audit.prev')}
|
||
</Button>
|
||
<span className="kx-adm-pager__page tnum">
|
||
{page + 1} / {totalPages}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||
disabled={page >= totalPages - 1}
|
||
>
|
||
{t('audit.next')}
|
||
</Button>
|
||
</div>
|
||
<label className="kx-adm-pager__size">
|
||
<span>{t('audit.rowsShow')}</span>
|
||
<select
|
||
className="kx-select"
|
||
value={size}
|
||
onChange={(e) => {
|
||
setSize(Number(e.target.value));
|
||
setPage(0);
|
||
}}
|
||
aria-label={t('audit.rowsPerPage')}
|
||
>
|
||
{PAGE_SIZES.map((s) => (
|
||
<option key={s} value={s}>
|
||
{t('audit.rowsN', { n: s })}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ResultPill({ value }: { value: string | null }) {
|
||
const { t } = useTranslation();
|
||
const v = (value ?? '').toUpperCase();
|
||
const tone =
|
||
v === 'SUCCESS' || v === 'OK'
|
||
? 'success'
|
||
: v === 'FAILURE' || v === 'FAIL' || v === 'ERROR' || v === 'DENIED'
|
||
? 'error'
|
||
: 'neutral';
|
||
const label = tone === 'success' ? t('audit.success') : tone === 'error' ? t('audit.failure') : value ?? '—';
|
||
return <span className={`kx-adm-pill kx-adm-pill--${tone}`}>{label}</span>;
|
||
}
|
||
|
||
function TableSkeleton() {
|
||
return (
|
||
<div className="kx-card" aria-hidden="true" style={{ display: 'grid', gap: 12 }}>
|
||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||
<Skeleton key={i} height={44} radius={6} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function initial(name: string | null | undefined): string {
|
||
const s = (name ?? '').trim();
|
||
return s ? s.charAt(0) : '?';
|
||
}
|
||
|
||
function fmtTs(s: string | null | undefined): string {
|
||
if (!s) return '—';
|
||
const d = new Date(s);
|
||
if (Number.isNaN(d.getTime())) return s;
|
||
const p = (n: number) => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||
}
|
||
|
||
/** 현재 로드된 페이지 rows 를 CSV 로 내보낸다. IP/자격증명 컬럼은 애초에 존재하지 않는다. */
|
||
function exportCsv(t: TFunction, rows: AuditLogRow[]): void {
|
||
if (rows.length === 0) return;
|
||
const headers = [
|
||
t('audit.csvTime'), t('audit.csvActorId'), t('audit.csvActorName'), t('audit.csvAction'),
|
||
t('audit.csvTargetType'), t('audit.csvTargetId'), t('audit.csvEventId'), t('audit.csvResult'), t('audit.csvSummary'),
|
||
];
|
||
const esc = (v: unknown) => {
|
||
const s = v == null ? '' : String(v);
|
||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||
};
|
||
const lines = [
|
||
headers.join(','),
|
||
...rows.map((r) =>
|
||
[r.createdAt, r.actorId, r.actorName, r.action, r.targetType, r.targetId, r.eventId, r.result, r.summary]
|
||
.map(esc)
|
||
.join(','),
|
||
),
|
||
];
|
||
const blob = new Blob(['' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|