kintex/src/frontend/src/screens/admin/AuditLogPage.tsx
zio 9d47f9373f feat(shell,tenant,i18n): wave 7 — MDI tab shell, multitenancy phase 2 (JWT tid + fail-closed guard), i18n sweep 2
- 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>
2026-07-12 11:51:00 +09:00

315 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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);
}