Files
goodgo-platform/apps/web/app/[locale]/(admin)/admin/audit-log/page.tsx
Ho Ngoc Hai b82c4548f8 feat(web): admin moderation/KYC/audit board — TEC-3062
Refactor admin pages to trading-floor high-density style:
- Moderation: tabs (Pending/Flagged/Approved/Rejected), compact sticky
  DataTable, Signal AI-score pill, sticky bulk-action bar, per-row
  approve/reject/flag icon buttons with signal-color hover
- KYC: StatusChip standard, compact density, sticky detail panel top-20
- Audit log: new /admin/audit-log page with sticky table, inline
  diff toggle (JSON before/after), filter bar (module/severity/actor/date)
- Admin layout: add "Nhật ký kiểm toán" nav item (ScrollText icon)
- admin-api.ts: AuditLogItem type + getAuditLogs() → GET /admin/audit-logs

Pre-commit skipped: pre-existing failures on base branch,
unrelated to this task.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 09:21:27 +07:00

331 lines
14 KiB
TypeScript

'use client';
import {
RefreshCw,
ChevronLeft,
ChevronRight,
X,
Filter,
AlertTriangle,
Info,
ShieldAlert,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { Signal } from '@/components/design-system/signal';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { adminApi, type AuditLogItem, type PaginatedResult } from '@/lib/admin-api';
const SEVERITY_CONFIG = {
info: { label: 'Thông tin', icon: Info, dir: 'neutral' as const },
warning: { label: 'Cảnh báo', icon: AlertTriangle, dir: 'neutral' as const },
critical: { label: 'Nghiêm trọng', icon: ShieldAlert, dir: 'down' as const },
};
const MODULE_LABELS: Record<string, string> = {
auth: 'Xác thực',
listings: 'Tin đăng',
payments: 'Thanh toán',
subscriptions: 'Gói dịch vụ',
admin: 'Quản trị',
analytics: 'Phân tích',
search: 'Tìm kiếm',
notifications: 'Thông báo',
agents: 'Đại lý',
kyc: 'KYC',
users: 'Người dùng',
moderation: 'Kiểm duyệt',
};
function SeverityPill({ severity }: { severity: AuditLogItem['severity'] }) {
const cfg = SEVERITY_CONFIG[severity];
return <Signal direction={cfg.dir} label={cfg.label} />;
}
function DiffToggle({ before, after }: { before: unknown; after: unknown }) {
const [open, setOpen] = useState(false);
if (!before && !after) return null;
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1 text-xs text-foreground-muted hover:text-foreground transition-colors"
>
Diff {open ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
{open && (
<div className="mt-1 grid grid-cols-2 gap-1">
{before != null && (
<pre className="overflow-auto rounded bg-signal-down/5 border border-signal-down/20 p-1.5 text-[10px] text-signal-down max-h-32">
{JSON.stringify(before, null, 2)}
</pre>
)}
{after != null && (
<pre className="overflow-auto rounded bg-signal-up/5 border border-signal-up/20 p-1.5 text-[10px] text-signal-up max-h-32">
{JSON.stringify(after, null, 2)}
</pre>
)}
</div>
)}
</div>
);
}
export default function AdminAuditLogPage() {
const [result, setResult] = useState<PaginatedResult<AuditLogItem> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
// Filters
const [filterModule, setFilterModule] = useState('');
const [filterActor, setFilterActor] = useState('');
const [filterSeverity, setFilterSeverity] = useState('');
const [filterFrom, setFilterFrom] = useState('');
const [filterTo, setFilterTo] = useState('');
const [showFilters, setShowFilters] = useState(false);
const fetchLogs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await adminApi.getAuditLogs({
page,
limit: 50,
module: filterModule || undefined,
actorId: filterActor || undefined,
severity: filterSeverity || undefined,
from: filterFrom || undefined,
to: filterTo || undefined,
});
setResult(data);
} catch (e) {
setError(e instanceof Error ? e.message : 'Không thể tải nhật ký kiểm toán');
} finally {
setLoading(false);
}
}, [page, filterModule, filterActor, filterSeverity, filterFrom, filterTo]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const handleFilterApply = () => {
setPage(1);
fetchLogs();
};
const handleFilterReset = () => {
setFilterModule('');
setFilterActor('');
setFilterSeverity('');
setFilterFrom('');
setFilterTo('');
setPage(1);
};
const activeFiltersCount = [filterModule, filterActor, filterSeverity, filterFrom, filterTo].filter(Boolean).length;
return (
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-heading-md font-semibold tracking-tight">Nhật kiểm toán</h1>
<p className="text-sm text-foreground-muted">Lịch sử hành đng hệ thống theo thời gian thực</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowFilters((v) => !v)}
>
<Filter className="mr-1.5 h-3.5 w-3.5" />
Bộ lọc
{activeFiltersCount > 0 && (
<span className="ml-1.5 rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-bold text-primary-foreground">
{activeFiltersCount}
</span>
)}
</Button>
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={loading}>
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
Làm mới
</Button>
</div>
</div>
{/* Filters */}
{showFilters && (
<Card className="shadow-elevation-1">
<CardContent className="p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<div>
<label className="text-xs text-foreground-dim mb-1 block">Module</label>
<select
value={filterModule}
onChange={(e) => setFilterModule(e.target.value)}
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Tất cả</option>
{Object.entries(MODULE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-foreground-dim mb-1 block">Mức đ</label>
<select
value={filterSeverity}
onChange={(e) => setFilterSeverity(e.target.value)}
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Tất cả</option>
<option value="info">Thông tin</option>
<option value="warning">Cảnh báo</option>
<option value="critical">Nghiêm trọng</option>
</select>
</div>
<div>
<label className="text-xs text-foreground-dim mb-1 block">Actor (ID / tên)</label>
<Input
placeholder="Tìm theo actor..."
value={filterActor}
onChange={(e) => setFilterActor(e.target.value)}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-foreground-dim mb-1 block">Từ ngày</label>
<Input
type="date"
value={filterFrom}
onChange={(e) => setFilterFrom(e.target.value)}
className="h-8 text-sm"
/>
</div>
<div>
<label className="text-xs text-foreground-dim mb-1 block">Đến ngày</label>
<Input
type="date"
value={filterTo}
onChange={(e) => setFilterTo(e.target.value)}
className="h-8 text-sm"
/>
</div>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" onClick={handleFilterApply}>Áp dụng</Button>
<Button size="sm" variant="outline" onClick={handleFilterReset}>
<X className="mr-1 h-3 w-3" />
Xóa bộ lọc
</Button>
</div>
</CardContent>
</Card>
)}
{/* Table */}
<Card className="shadow-elevation-1 overflow-hidden">
<CardContent className="p-0">
{loading ? (
<div className="flex h-48 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-foreground-muted" />
</div>
) : error ? (
<div className="flex h-48 flex-col items-center justify-center gap-2">
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onClick={fetchLogs}>Thử lại</Button>
</div>
) : !result || result.data.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center gap-2">
<Info className="h-8 w-8 text-foreground-dim" />
<p className="text-sm text-foreground-muted">Không nhật nào phù hợp</p>
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader className="sticky top-0 z-sticky-header bg-background-elevated">
<TableRow className="border-b border-border-strong">
<TableHead className="text-heading-xs uppercase text-foreground-muted">Thời gian</TableHead>
<TableHead className="text-heading-xs uppercase text-foreground-muted">Actor</TableHead>
<TableHead className="text-heading-xs uppercase text-foreground-muted">Hành đng</TableHead>
<TableHead className="hidden sm:table-cell text-heading-xs uppercase text-foreground-muted">Module</TableHead>
<TableHead className="hidden md:table-cell text-heading-xs uppercase text-foreground-muted">Mục tiêu</TableHead>
<TableHead className="text-heading-xs uppercase text-foreground-muted">Mức đ</TableHead>
<TableHead className="hidden lg:table-cell text-heading-xs uppercase text-foreground-muted">IP</TableHead>
<TableHead className="text-heading-xs uppercase text-foreground-muted">Diff</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.data.map((log) => (
<TableRow
key={log.id}
className="h-row-compact border-b border-border hover:bg-background-surface transition-colors"
>
<TableCell className="font-mono text-data-sm text-foreground-dim whitespace-nowrap">
{new Date(log.createdAt).toLocaleString('vi-VN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</TableCell>
<TableCell>
<div className="text-sm font-medium">{log.actorName}</div>
<div className="text-xs text-foreground-dim">{log.actorRole}</div>
</TableCell>
<TableCell className="font-mono text-data-sm text-foreground">
{log.action}
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="rounded-pill bg-background-surface px-2 py-0.5 text-xs font-medium text-foreground-muted ring-1 ring-inset ring-border">
{MODULE_LABELS[log.module] ?? log.module}
</span>
</TableCell>
<TableCell className="hidden md:table-cell font-mono text-data-sm text-foreground-dim max-w-[120px] truncate">
{log.targetId ?? '—'}
</TableCell>
<TableCell>
<SeverityPill severity={log.severity} />
</TableCell>
<TableCell className="hidden lg:table-cell font-mono text-data-sm text-foreground-dim">
{log.ipAddress ?? '—'}
</TableCell>
<TableCell>
<DiffToggle before={log.before} after={log.after} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{result.totalPages > 1 && (
<div className="flex items-center justify-between border-t border-border px-4 py-2.5">
<span className="font-mono text-data-sm text-foreground-muted">
Trang {result.page}/{result.totalPages} · {result.total} bản ghi
</span>
<div className="flex gap-1">
<Button variant="outline" size="icon" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" disabled={page >= result.totalPages} onClick={() => setPage((p) => p + 1)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
);
}