Commit 6783e17c authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/profile-enhancements-and-dashboard-cleanup' into 'develop'

feat(profile,dashboard): add user ID & joined date, password toggles, and cleanup health bar

See merge request !13
parents b628b72c c380673d
......@@ -131,10 +131,10 @@ Tất cả các trang khi phát triển mới hoặc bảo trì **BẮT BUỘC P
---
## 8. Quy Chuẩn Giao Diện Dropdown & Đa Ngôn Ngữ (Dropdown & i18n Standards)
## 9. Quy Chuẩn Giao Diện Dropdown & Đa Ngôn Ngữ (Dropdown & i18n Standards)
- **Quy chuẩn Custom Dropdown (ThemeToggle Pattern)**:
- **TUYỆT ĐỐI KHÔNG** dùng thẻ `<select>` mặc định của trình duyệt cho các bộ chọn tùy chỉnh trong modal/form.
- Bắt buộc thiết kế Custom Dropdown theo phong cách của `ThemeToggle`:
- Bắt buộc thiết kế Custom Dropdown theo phong cách của `ThemeToggle` hoặc sử dụng `PageSizeSelect` (`src/components/common/page-size-select.tsx`) cho bộ chọn số dòng mỗi trang.
- **Trigger Button**: Bo góc mềm `rounded-2xl`, viền `border-border/80`, có icon phân loại, tên tùy chọn đang chọn và icon `<ChevronDown />` xoay 180° khi mở (`isDropdownOpen ? "rotate-180" : ""`).
- **Menu Popup**: `absolute``rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150`.
- **Option Item**: Thẻ `button` bo góc `rounded-xl px-2.5 py-2 text-xs flex items-center justify-between`. Khi được chọn (`isSelected`), hiển thị nền xanh nhẹ `bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20` kèm icon `<Check />`.
......@@ -144,3 +144,29 @@ Tất cả các trang khi phát triển mới hoặc bảo trì **BẮT BUỘC P
- **Đồng bộ hóa 100% Tiếng Anh**: Mọi khóa trong `translations.vi` đều phải có khóa tương ứng trong `translations.en`.
- **Định dạng ngày tháng theo Locale**: Hàm `formatDate(date, locale)` bắt buộc phải nhận `locale` từ hook `useLanguage()`, định dạng `en-US` khi Tiếng Anh và `vi-VN` khi Tiếng Việt để tránh dính chữ tiếng Việt (như `thg`) trên giao diện tiếng Anh.
---
## 10. Quy Chuẩn Thiết Kế & Đồng Bộ Bảng Dữ Liệu (Unified Table Standards) - BẮT BUỘC
Tất cả các bảng dữ liệu trong hệ thống (Tác vụ cào, Nhật ký kiểm toán, Quản lý người dùng, Tệp xuất,...) **BẮT BUỘC PHẢI TUÂN THỦ ĐỒNG BỘ 100%**:
- **Cấu trúc Thẻ Bảng Liền Mạch (Unified Table Card)**:
- Bảng dữ liệu và thanh phân trang chân bảng **BẮT BUỘC** phải nằm chung bên trong một card bo góc duy nhất: `overflow-hidden rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm transition-colors`.
- **TUYỆT ĐỐI KHÔNG** đặt thanh phân trang tách rời thành một container bên ngoài bảng.
- **Thanh Phân Trang Chân Bảng (TablePagination Component)**:
- Sử dụng component dùng chung `TablePagination` (`src/components/common/table-pagination.tsx`).
- Định dạng hiển thị bắt buộc:
- Bên trái: `Tổng số: <X> • Trang <Y> trên <Z>` (Việt) / `Total: <X> • Page <Y> of <Z>` (Anh).
- Bên phải: Nhãn `bản ghi / trang:` kết hợp component `PageSizeSelect` (mặc định mở hướng lên `direction="up"`) và 2 nút phân trang bo tròn nhỏ gọn `[<]``[>]`.
- Phân tách với phần thân bảng bằng viền `border-t border-border/70 bg-muted/20 px-4 py-3 text-xs text-muted-foreground`.
- **Tiêu Đề Cột Có Sắp Xếp (TableSortHeader Component)**:
- Tất cả các cột có hỗ trợ sắp xếp **BẮT BUỘC** sử dụng component `TableSortHeader` (`src/components/common/table-sort-header.tsx`).
- **Quy chuẩn hiển thị trạng thái**:
- Khi cột đang được sắp xếp: Chữ in đậm màu xanh emerald (`text-emerald-600 dark:text-emerald-400 font-bold`) kèm icon `<ArrowUp />` khi tăng dần (`asc`) hoặc `<ArrowDown />` khi giảm dần (`desc`).
- Khi cột chưa kích hoạt: Hiển thị icon `<ArrowUpDown />` với độ mờ nhẹ (`text-muted-foreground/50`), làm sáng màu khi hover.
- **Tránh Xung Đột Lớp Hiển Thị Dropdown & Bảng (Stacking Context & Z-Index)**:
- Khối bộ lọc phía trên bảng phải có `relative z-30` (hoặc `z-20`).
- Container bảng dữ liệu phải có `relative z-10` (hoặc `z-0`).
- Ngăn ngừa hiện tượng menu dropdown của bộ lọc bị bảng dữ liệu bên dưới che khuất.
- **Tối Giản Bố Cục Màn Hình Phân Hệ Quản Trị / Nhật Ký**:
- Không đặt các ô thống kê KPI rườm rà (Tổng số, Tạo mới, Cập nhật, Xóa) trên màn hình Nhật ký kiểm toán (`/audit-logs`) để tập trung tối đa không gian cho bảng dữ liệu dòng thời gian và bộ lọc nâng cao.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Nhật Ký Kiểm Toán",
description:
"Nhật ký kiểm toán và an toàn hệ thống, theo dõi toàn bộ hoạt động, thay đổi dữ liệu và truy cập an ninh theo thời gian thực trong Data Crawler.",
};
export default function AuditLogsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
"use client";
import React from "react";
import { AuditLogsView } from "@/components/audit-logs/audit-logs-view";
export default function AuditLogsPage() {
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 animate-in fade-in-50 duration-200">
<AuditLogsView showHeader={true} />
</div>
);
}
import type { Metadata } from "next";
import { AuditLogsView } from "@/components/audit-logs/audit-logs-view";
export const metadata: Metadata = {
title: "Nhật Ký Kiểm Toán | Nhà Phát Triển",
description:
"Theo dõi toàn bộ các hoạt động, thay đổi dữ liệu và sự kiện an ninh hệ thống trong Data Crawler.",
};
export default function DeveloperAuditLogsPage() {
return (
<div className="space-y-6 animate-in fade-in-50 duration-200">
<AuditLogsView showHeader={false} />
</div>
);
}
......@@ -17,12 +17,17 @@ import {
Check,
X,
Sparkles,
Eye,
EyeOff,
Copy,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
import { formatDate } from "@/lib/utils";
import { toast } from "sonner";
import {
useUserProfile,
useUserUsage,
......@@ -51,14 +56,21 @@ export default function ProfileSettingsPage() {
const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.avatarUrl || null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Password form states
// Copy ID state
const [hasCopiedId, setHasCopiedId] = useState(false);
// Password form states & visibility toggles
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
// Deactivate modal state
// Deactivate modal state & visibility toggle
const [isDeactivateModalOpen, setIsDeactivateModalOpen] = useState(false);
const [deactivatePassword, setDeactivatePassword] = useState("");
const [showDeactivatePassword, setShowDeactivatePassword] = useState(false);
// Keep state synced with user profile
React.useEffect(() => {
......@@ -159,6 +171,21 @@ export default function ProfileSettingsPage() {
.substring(0, 2)
.toUpperCase();
const userId = profile?.id || user?.id || "";
const joinedDate = profile?.createdAt || user?.createdAt || "";
const handleCopyId = async () => {
if (!userId) return;
try {
await navigator.clipboard.writeText(userId);
setHasCopiedId(true);
toast.success(t.profile.personal.copiedId);
setTimeout(() => setHasCopiedId(false), 2000);
} catch {
toast.error("Không thể sao chép ID");
}
};
return (
<div className="min-h-screen bg-background pb-20">
<div className="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 pt-8 space-y-8">
......@@ -244,8 +271,54 @@ export default function ProfileSettingsPage() {
</div>
</div>
{/* Inputs: Full Name & Email */}
{/* Inputs: User ID & Joined Date (Row 1), Full Name & Email (Row 2) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="prof-id" className="text-xs font-semibold">
{t.profile.personal.userIdLabel}
</Label>
{userId && (
<button
type="button"
onClick={handleCopyId}
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors cursor-pointer"
title={hasCopiedId ? t.profile.personal.copiedId : t.profile.personal.copyId}
>
{hasCopiedId ? (
<>
<Check className="h-3 w-3 text-emerald-500" />
<span className="text-emerald-500 font-medium">{t.profile.personal.copiedId}</span>
</>
) : (
<>
<Copy className="h-3 w-3" />
<span>{t.profile.personal.copyId}</span>
</>
)}
</button>
)}
</div>
<Input
id="prof-id"
value={userId || "—"}
disabled
className="rounded-2xl text-xs opacity-70 bg-muted/40 font-mono select-all"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="prof-joined" className="text-xs font-semibold">
{t.profile.personal.joinedDateLabel}
</Label>
<Input
id="prof-joined"
value={joinedDate ? formatDate(joinedDate, locale) : "—"}
disabled
className="rounded-2xl text-xs opacity-70 bg-muted/40"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="prof-name" className="text-xs font-semibold">
{t.profile.personal.nameLabel}
......@@ -337,28 +410,58 @@ export default function ProfileSettingsPage() {
<Label className="text-xs font-semibold">
{t.profile.password.currentLabel}
</Label>
<Input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder={t.profile.password.currentPlaceholder}
className="rounded-2xl text-xs"
required
/>
<div className="relative">
<Input
type={showCurrentPassword ? "text" : "password"}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder={t.profile.password.currentPlaceholder}
className="rounded-2xl text-xs pr-10"
required
/>
<button
type="button"
onClick={() => setShowCurrentPassword((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors p-1 rounded-lg focus:outline-none cursor-pointer"
title={showCurrentPassword ? t.profile.password.hidePassword : t.profile.password.showPassword}
aria-label={showCurrentPassword ? t.profile.password.hidePassword : t.profile.password.showPassword}
>
{showCurrentPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.profile.password.newLabel}
</Label>
<Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t.profile.password.newPlaceholder}
className="rounded-2xl text-xs"
required
/>
<div className="relative">
<Input
type={showNewPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t.profile.password.newPlaceholder}
className="rounded-2xl text-xs pr-10"
required
/>
<button
type="button"
onClick={() => setShowNewPassword((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors p-1 rounded-lg focus:outline-none cursor-pointer"
title={showNewPassword ? t.profile.password.hidePassword : t.profile.password.showPassword}
aria-label={showNewPassword ? t.profile.password.hidePassword : t.profile.password.showPassword}
>
{showNewPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{/* Password Strength Indicator */}
{newPassword && (
......@@ -403,14 +506,29 @@ export default function ProfileSettingsPage() {
<Label className="text-xs font-semibold">
{t.profile.password.confirmLabel}
</Label>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t.profile.password.confirmPlaceholder}
className="rounded-2xl text-xs"
required
/>
<div className="relative">
<Input
type={showConfirmPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t.profile.password.confirmPlaceholder}
className="rounded-2xl text-xs pr-10"
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors p-1 rounded-lg focus:outline-none cursor-pointer"
title={showConfirmPassword ? t.profile.password.hidePassword : t.profile.password.showPassword}
aria-label={showConfirmPassword ? t.profile.password.hidePassword : t.profile.password.showPassword}
>
{showConfirmPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{confirmPassword && newPassword !== confirmPassword && (
<p className="text-[10px] text-red-500">Mật khẩu xác nhận không khớp</p>
)}
......@@ -538,14 +656,29 @@ export default function ProfileSettingsPage() {
<Label className="text-xs font-semibold">
{t.profile.danger.passwordLabel}
</Label>
<Input
type="password"
value={deactivatePassword}
onChange={(e) => setDeactivatePassword(e.target.value)}
placeholder={t.profile.danger.passwordPlaceholder}
className="rounded-2xl text-xs"
required
/>
<div className="relative">
<Input
type={showDeactivatePassword ? "text" : "password"}
value={deactivatePassword}
onChange={(e) => setDeactivatePassword(e.target.value)}
placeholder={t.profile.danger.passwordPlaceholder}
className="rounded-2xl text-xs pr-10"
required
/>
<button
type="button"
onClick={() => setShowDeactivatePassword((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors p-1 rounded-lg focus:outline-none cursor-pointer"
title={showDeactivatePassword ? t.profile.password.hidePassword : t.profile.password.showPassword}
aria-label={showDeactivatePassword ? t.profile.password.hidePassword : t.profile.password.showPassword}
>
{showDeactivatePassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<div className="flex items-center justify-end gap-2.5 pt-2">
......
import { redirect } from "next/navigation";
import type { Metadata } from "next";
import { UsersManagementView } from "@/components/users/users-management-view";
export const metadata: Metadata = {
title: "Quản Lý Người Dùng | Data Crawler",
description:
"Giao diện quản trị thành viên, phân quyền vai trò RBAC và kiểm soát trạng thái tài khoản người dùng trong hệ thống Data Crawler.",
};
export default function UsersPage() {
redirect("/settings/developer/users");
return (
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 animate-in fade-in-50 duration-200">
<UsersManagementView showHeader={true} />
</div>
);
}
"use client";
import React, { useState, useEffect } from "react";
import {
X,
Copy,
Check,
AlertTriangle,
Clock,
User,
Globe,
Monitor,
Shield,
FileCode,
ArrowRight,
Database,
Key,
Layers,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AuditLogItem,
getActionCategory,
getTargetResource,
} from "@/types/audit-log";
import { useLanguage } from "@/providers/language-provider";
import {
formatDate,
formatRelativeTime,
parseUserAgent,
cn,
} from "@/lib/utils";
interface AuditLogDetailDrawerProps {
log: AuditLogItem | null;
isOpen: boolean;
onClose: () => void;
}
export function AuditLogDetailDrawer({
log,
isOpen,
onClose,
}: AuditLogDetailDrawerProps) {
const { t, locale } = useLanguage();
const [copiedPayload, setCopiedPayload] = useState(false);
const [copiedIp, setCopiedIp] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false);
// Handle ESC key to close
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
};
}, [isOpen, onClose]);
if (!isOpen || !log) return null;
const category = getActionCategory(log.action);
const resource = getTargetResource(log);
const ua = parseUserAgent(log.userAgent);
const details = log.details || {};
// Check error state
const isError =
Boolean(details.error) ||
details.status === "FAILED" ||
log.action.includes("BLOCKED") ||
log.action.includes("ATTEMPT");
const errorMessage =
details.error ||
details.message ||
(log.action === "PRIVILEGE_ESCALATION_BLOCKED"
? "Phát hiện và ngăn chặn nỗ lực leo thang quyền lực trái phép."
: null);
// Before / After data diff detection
const beforeData =
details.before || (details.changes && details.changes.before);
const afterData = details.after || (details.changes && details.changes.after);
const hasDiff = Boolean(beforeData || afterData);
const updatedFields = Array.isArray(details.updatedFields)
? details.updatedFields
: null;
// Copy JSON handler
const handleCopyPayload = () => {
navigator.clipboard.writeText(JSON.stringify(details, null, 2));
setCopiedPayload(true);
setTimeout(() => setCopiedPayload(false), 2000);
};
const handleCopyIp = () => {
if (log.ipAddress) {
navigator.clipboard.writeText(log.ipAddress);
setCopiedIp(true);
setTimeout(() => setCopiedIp(false), 2000);
}
};
const handleCopyUserId = () => {
if (log.userId) {
navigator.clipboard.writeText(log.userId);
setCopiedUserId(true);
setTimeout(() => setCopiedUserId(false), 2000);
}
};
const renderActionBadge = () => {
switch (category) {
case "CREATE":
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/15 px-3 py-1 text-xs font-bold text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
{log.action}
</span>
);
case "UPDATE":
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/15 px-3 py-1 text-xs font-bold text-amber-600 dark:text-amber-400 border border-amber-500/30">
<span className="h-2 w-2 rounded-full bg-amber-500" />
{log.action}
</span>
);
case "DELETE":
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-red-500/15 px-3 py-1 text-xs font-bold text-red-600 dark:text-red-400 border border-red-500/30">
<span className="h-2 w-2 rounded-full bg-red-500" />
{log.action}
</span>
);
case "LOGIN":
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-500/15 px-3 py-1 text-xs font-bold text-blue-600 dark:text-blue-400 border border-blue-500/30">
<span className="h-2 w-2 rounded-full bg-blue-500" />
{log.action}
</span>
);
default:
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-teal-500/15 px-3 py-1 text-xs font-bold text-teal-600 dark:text-teal-400 border border-teal-500/30">
<span className="h-2 w-2 rounded-full bg-teal-500" />
{log.action}
</span>
);
}
};
return (
<div className="fixed inset-0 z-50 overflow-hidden">
{/* Backdrop overlay */}
<div
onClick={onClose}
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity animate-in fade-in duration-200"
/>
{/* Slide-over Drawer Panel */}
<div className="fixed inset-y-0 right-0 max-w-full flex pl-10">
<div className="w-screen max-w-2xl bg-card border-l border-emerald-500/20 shadow-2xl shadow-emerald-950/20 flex flex-col animate-in slide-in-from-right duration-300">
{/* Drawer Header */}
<div className="px-6 py-5 border-b border-border/70 bg-card/80 backdrop-blur-md flex items-center justify-between gap-4">
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{renderActionBadge()}
<span className="text-xs text-muted-foreground font-mono">
ID: {log.id.slice(0, 13)}...
</span>
</div>
<h2 className="text-lg font-bold text-foreground truncate">
{t.auditLogs.drawer.title}
</h2>
</div>
<button
onClick={onClose}
className="rounded-2xl p-2 text-muted-foreground hover:text-foreground hover:bg-muted/70 transition-colors cursor-pointer border border-border/60"
title={t.auditLogs.drawer.close}
>
<X className="h-5 w-5" />
</button>
</div>
{/* Drawer Body Scrollable Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6 text-foreground">
{/* Timestamp & Resource Alert */}
<div className="rounded-2xl border border-border/70 bg-muted/40 p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs">
<Clock className="h-4 w-4 text-emerald-500 shrink-0" />
<div>
<span className="font-semibold text-foreground">
{formatRelativeTime(log.createdAt, locale)}
</span>
<span className="text-muted-foreground block text-[11px]">
{formatDate(log.createdAt, locale)}
</span>
</div>
</div>
{/* Target Resource */}
<div className="inline-flex items-center gap-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 text-xs font-semibold text-emerald-600 dark:text-emerald-400">
<Database className="h-3.5 w-3.5" />
<span>
{t.auditLogs.table.targetResource}: {resource.label}
</span>
</div>
</div>
{/* Error Notification Banner (if failed) */}
{isError && (
<div className="rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-xs space-y-1.5">
<div className="flex items-center gap-2 font-bold text-red-600 dark:text-red-400">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{t.auditLogs.drawer.errorAlert}</span>
</div>
<p className="text-red-700 dark:text-red-300 pl-6 leading-relaxed">
{errorMessage || t.auditLogs.drawer.errorDesc}
</p>
</div>
)}
{/* Actor Context Information Card */}
<div className="rounded-2xl border border-emerald-500/15 bg-card/60 p-5 space-y-4">
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-muted-foreground">
<User className="h-4 w-4 text-emerald-500" />
<span>{t.auditLogs.drawer.actorInfo}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
{/* Full Name & Email */}
<div className="space-y-1">
<span className="text-muted-foreground text-[11px] block">
{t.auditLogs.table.actor}
</span>
<p className="font-semibold text-foreground text-sm">
{log.user?.fullName || log.user?.email || t.auditLogs.table.anonymous}
</p>
{log.user?.email && (
<p className="text-muted-foreground text-xs truncate">
{log.user.email}
</p>
)}
</div>
{/* Role Badge */}
<div className="space-y-1">
<span className="text-muted-foreground text-[11px] block">
{t.auditLogs.drawer.userRole}
</span>
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2.5 py-0.5 font-semibold text-xs border border-emerald-500/20">
<Shield className="h-3 w-3" />
{log.user?.role || "USER"}
</span>
</div>
{/* User ID */}
{log.userId && (
<div className="space-y-1">
<span className="text-muted-foreground text-[11px] block">
{t.auditLogs.drawer.userId}
</span>
<div className="flex items-center gap-1.5 font-mono text-[11px] text-foreground">
<span className="truncate">{log.userId}</span>
<button
onClick={handleCopyUserId}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
title="Sao chép User ID"
>
{copiedUserId ? (
<Check className="h-3 w-3 text-emerald-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
</div>
)}
{/* IP Address */}
<div className="space-y-1">
<span className="text-muted-foreground text-[11px] block">
{t.auditLogs.drawer.ipAddress}
</span>
<div className="flex items-center gap-1.5 font-mono text-xs font-semibold text-foreground">
<Globe className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span>{log.ipAddress || "127.0.0.1"}</span>
{log.ipAddress && (
<button
onClick={handleCopyIp}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
title="Sao chép IP"
>
{copiedIp ? (
<Check className="h-3 w-3 text-emerald-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
)}
</div>
</div>
</div>
{/* User-Agent Breakdown */}
<div className="pt-3 border-t border-border/50 space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground flex items-center gap-1.5">
<Monitor className="h-3.5 w-3.5 text-emerald-500" />
{t.auditLogs.drawer.parsedClient}:
</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-lg text-[11px]">
{ua.short}
</span>
</div>
{log.userAgent && (
<p className="font-mono text-[10px] text-muted-foreground bg-muted/50 p-2 rounded-xl break-all border border-border/40">
{log.userAgent}
</p>
)}
</div>
</div>
{/* Before / After Data Diff (If present) */}
{hasDiff && (
<div className="rounded-2xl border border-emerald-500/20 bg-card/70 p-5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-foreground">
<Sparkles className="h-4 w-4 text-emerald-500" />
<span>{t.auditLogs.drawer.diffTitle}</span>
</div>
{updatedFields && updatedFields.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
<span className="text-[10px] text-muted-foreground">
{t.auditLogs.drawer.modifiedFields}:
</span>
{updatedFields.map((field) => (
<span
key={field}
className="bg-muted px-1.5 py-0.5 rounded text-[10px] font-mono font-medium border border-border/70"
>
{field}
</span>
))}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
{/* Before Column */}
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-3.5 space-y-2">
<div className="font-bold text-red-600 dark:text-red-400 text-xs flex items-center gap-1.5 pb-1.5 border-b border-red-500/15">
<span className="h-2 w-2 rounded-full bg-red-500" />
{t.auditLogs.drawer.diffBefore}
</div>
<pre className="font-mono text-[11px] text-red-700 dark:text-red-300 overflow-x-auto whitespace-pre-wrap leading-relaxed">
{beforeData
? JSON.stringify(beforeData, null, 2)
: "(Không có giá trị ban đầu)"}
</pre>
</div>
{/* After Column */}
<div className="rounded-xl border border-emerald-500/25 bg-emerald-500/5 p-3.5 space-y-2">
<div className="font-bold text-emerald-600 dark:text-emerald-400 text-xs flex items-center gap-1.5 pb-1.5 border-b border-emerald-500/15">
<span className="h-2 w-2 rounded-full bg-emerald-500" />
{t.auditLogs.drawer.diffAfter}
</div>
<pre className="font-mono text-[11px] text-emerald-700 dark:text-emerald-300 overflow-x-auto whitespace-pre-wrap leading-relaxed">
{afterData
? JSON.stringify(afterData, null, 2)
: "(Không có giá trị mới)"}
</pre>
</div>
</div>
</div>
)}
{/* Request Payload JSON Inspector */}
<div className="rounded-2xl border border-emerald-500/20 bg-card/60 p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-foreground">
<FileCode className="h-4 w-4 text-emerald-500" />
<span>{t.auditLogs.drawer.payloadTitle}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleCopyPayload}
className="h-7 px-2.5 rounded-xl text-xs text-muted-foreground hover:text-emerald-500 hover:bg-emerald-500/10 cursor-pointer transition-colors"
>
{copiedPayload ? (
<>
<Check className="h-3.5 w-3.5 mr-1 text-emerald-500" />
<span className="text-emerald-500 font-semibold">
{t.auditLogs.drawer.copied}
</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5 mr-1" />
<span>{t.auditLogs.drawer.copyJson}</span>
</>
)}
</Button>
</div>
{Object.keys(details).length > 0 ? (
<div className="relative rounded-xl border border-emerald-500/20 bg-[#061a14]/90 p-4 text-[#A8D5BA] overflow-x-auto shadow-inner">
<pre className="font-mono text-xs leading-relaxed">
{JSON.stringify(details, null, 2)}
</pre>
</div>
) : (
<p className="text-xs text-muted-foreground italic py-3 text-center">
{t.auditLogs.drawer.noPayload}
</p>
)}
</div>
</div>
{/* Drawer Footer */}
<div className="p-4 border-t border-border/70 bg-muted/30 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={onClose}
className="rounded-xl px-5 text-xs font-semibold cursor-pointer hover:bg-muted"
>
{t.auditLogs.drawer.close}
</Button>
</div>
</div>
</div>
</div>
);
}
"use client";
import React, { useState, useRef, useEffect } from "react";
import {
Search,
Filter,
Calendar,
RotateCcw,
ChevronDown,
Check,
X,
SlidersHorizontal,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { cn } from "@/lib/utils";
export type DatePreset = "all" | "today" | "7d" | "30d" | "custom";
interface AuditLogsFiltersProps {
search: string;
onSearchChange: (value: string) => void;
action: string;
onActionChange: (action: string) => void;
datePreset: DatePreset;
onDatePresetChange: (preset: DatePreset) => void;
startDate: string;
endDate: string;
onCustomDateChange: (start: string, end: string) => void;
onReset: () => void;
hasActiveFilters: boolean;
}
const ACTION_OPTIONS = [
{ value: "ALL", labelVi: "Tất cả hành động", labelEn: "All Actions", category: "all" },
// Category Groups
{ value: "CREATE_JOB", labelVi: "Khởi tạo tác vụ cào (CREATE_JOB)", labelEn: "Create Crawl Job (CREATE_JOB)", category: "create" },
{ value: "CANCEL_JOB", labelVi: "Hủy tác vụ cào (CANCEL_JOB)", labelEn: "Cancel Crawl Job (CANCEL_JOB)", category: "delete" },
{ value: "DOWNLOAD_EXPORT", labelVi: "Tải file trích xuất (DOWNLOAD_EXPORT)", labelEn: "Download Export (DOWNLOAD_EXPORT)", category: "login" },
{ value: "LOGIN", labelVi: "Đăng nhập hệ thống (LOGIN)", labelEn: "User Login (LOGIN)", category: "login" },
{ value: "LOGOUT", labelVi: "Đăng xuất (LOGOUT)", labelEn: "User Logout (LOGOUT)", category: "login" },
{ value: "REGISTER", labelVi: "Đăng ký tài khoản (REGISTER)", labelEn: "Register Account (REGISTER)", category: "login" },
{ value: "ADMIN_CREATE_USER", labelVi: "Quản trị tạo người dùng (ADMIN_CREATE_USER)", labelEn: "Admin Create User (ADMIN_CREATE_USER)", category: "create" },
{ value: "ADMIN_UPDATE_USER", labelVi: "Quản trị sửa người dùng (ADMIN_UPDATE_USER)", labelEn: "Admin Update User (ADMIN_UPDATE_USER)", category: "update" },
{ value: "ADMIN_DELETE_USER", labelVi: "Quản trị xóa người dùng (ADMIN_DELETE_USER)", labelEn: "Admin Delete User (ADMIN_DELETE_USER)", category: "delete" },
{ value: "ROLE_CREATED", labelVi: "Tạo vai trò mới (ROLE_CREATED)", labelEn: "Create Role (ROLE_CREATED)", category: "create" },
{ value: "ROLE_UPDATED", labelVi: "Cập nhật vai trò (ROLE_UPDATED)", labelEn: "Update Role (ROLE_UPDATED)", category: "update" },
{ value: "ROLE_DELETED", labelVi: "Xóa vai trò (ROLE_DELETED)", labelEn: "Delete Role (ROLE_DELETED)", category: "delete" },
{ value: "ROLE_ASSIGNED", labelVi: "Gán vai trò cho người dùng (ROLE_ASSIGNED)", labelEn: "Assign Role to User (ROLE_ASSIGNED)", category: "create" },
{ value: "ROLE_REVOKED", labelVi: "Thu hồi vai trò (ROLE_REVOKED)", labelEn: "Revoke Role from User (ROLE_REVOKED)", category: "delete" },
{ value: "CREATE_API_KEY", labelVi: "Tạo khóa API (CREATE_API_KEY)", labelEn: "Create API Key (CREATE_API_KEY)", category: "create" },
{ value: "REVOKE_API_KEY", labelVi: "Thu hồi khóa API (REVOKE_API_KEY)", labelEn: "Revoke API Key (REVOKE_API_KEY)", category: "delete" },
{ value: "CREATE_WEBHOOK_CONFIG", labelVi: "Tạo cấu hình Webhook (CREATE_WEBHOOK)", labelEn: "Create Webhook (CREATE_WEBHOOK)", category: "create" },
{ value: "DELETE_WEBHOOK_CONFIG", labelVi: "Xóa cấu hình Webhook (DELETE_WEBHOOK)", labelEn: "Delete Webhook (DELETE_WEBHOOK)", category: "delete" },
{ value: "PRIVILEGE_ESCALATION_BLOCKED", labelVi: "Chặn leo thang quyền lực (SECURITY)", labelEn: "Privilege Escalation Blocked (SECURITY)", category: "delete" },
];
export function AuditLogsFilters({
search,
onSearchChange,
action,
onActionChange,
datePreset,
onDatePresetChange,
startDate,
endDate,
onCustomDateChange,
onReset,
hasActiveFilters,
}: AuditLogsFiltersProps) {
const { t, locale } = useLanguage();
const isVi = locale === "vi";
// Action Dropdown State
const [isActionDropdownOpen, setIsActionDropdownOpen] = useState(false);
const actionDropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click or Escape
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
actionDropdownRef.current &&
!actionDropdownRef.current.contains(e.target as Node)
) {
setIsActionDropdownOpen(false);
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") {
setIsActionDropdownOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, []);
const selectedActionObj = ACTION_OPTIONS.find(
(opt) => opt.value === action
) || ACTION_OPTIONS[0];
return (
<div className="space-y-3.5">
{/* Search and Primary Filters Bar */}
<div className="relative z-20 flex flex-col lg:flex-row lg:items-center gap-3">
{/* Search Bar */}
<div className="relative flex-1">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t.auditLogs.filters.searchPlaceholder}
className="pl-9 pr-8 h-10 rounded-2xl border-border/80 bg-card/70 focus-visible:ring-emerald-500/30 text-xs sm:text-sm"
/>
{search && (
<button
onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
title="Xóa tìm kiếm"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Action Type Custom Dropdown */}
<div
className={cn("relative min-w-[240px]", isActionDropdownOpen && "z-50")}
ref={actionDropdownRef}
>
<button
type="button"
onClick={() => setIsActionDropdownOpen(!isActionDropdownOpen)}
className="w-full flex items-center justify-between gap-2 h-10 px-3.5 rounded-2xl border border-border/80 bg-card/70 text-xs sm:text-sm font-medium hover:bg-muted/50 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-emerald-500/30 text-foreground"
>
<div className="flex items-center gap-2 truncate">
<SlidersHorizontal className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
<span className="truncate">
{isVi ? selectedActionObj.labelVi : selectedActionObj.labelEn}
</span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200 shrink-0",
isActionDropdownOpen && "rotate-180"
)}
/>
</button>
{/* Action Popup Menu */}
{isActionDropdownOpen && (
<div className="absolute right-0 mt-2 w-full sm:w-80 max-h-72 overflow-y-auto rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t.auditLogs.filters.actionFilter}
</div>
<div className="space-y-0.5 mt-1">
{ACTION_OPTIONS.map((opt) => {
const isSelected = action === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => {
onActionChange(opt.value === "ALL" ? "" : opt.value);
setIsActionDropdownOpen(false);
}}
className={cn(
"w-full rounded-xl px-2.5 py-2 text-xs flex items-center justify-between transition-colors cursor-pointer text-left",
isSelected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20"
: "text-foreground hover:bg-muted/70"
)}
>
<span className="truncate pr-2">
{isVi ? opt.labelVi : opt.labelEn}
</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
)}
</button>
);
})}
</div>
</div>
)}
</div>
{/* Reset Filters Button */}
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={onReset}
className="h-10 px-3.5 rounded-2xl text-xs font-semibold text-muted-foreground hover:text-red-500 hover:bg-red-500/10 border border-border/70 shrink-0 transition-colors cursor-pointer"
title={t.auditLogs.filters.reset}
>
<RotateCcw className="h-3.5 w-3.5 mr-1.5" />
<span>{t.auditLogs.filters.reset}</span>
</Button>
)}
</div>
{/* Date Range Presets Bar */}
<div className="relative z-10 flex flex-wrap items-center gap-2 text-xs">
<span className="text-muted-foreground font-medium flex items-center gap-1 shrink-0 mr-1">
<Calendar className="h-3.5 w-3.5 text-emerald-500" />
{t.auditLogs.filters.dateRange}:
</span>
<button
type="button"
onClick={() => onDatePresetChange("all")}
className={cn(
"rounded-xl px-3 py-1 font-medium transition-colors cursor-pointer border",
datePreset === "all"
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 font-semibold shadow-xs"
: "border-border/60 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
{t.auditLogs.filters.allTime}
</button>
<button
type="button"
onClick={() => onDatePresetChange("today")}
className={cn(
"rounded-xl px-3 py-1 font-medium transition-colors cursor-pointer border",
datePreset === "today"
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 font-semibold shadow-xs"
: "border-border/60 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
{t.auditLogs.filters.today}
</button>
<button
type="button"
onClick={() => onDatePresetChange("7d")}
className={cn(
"rounded-xl px-3 py-1 font-medium transition-colors cursor-pointer border",
datePreset === "7d"
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 font-semibold shadow-xs"
: "border-border/60 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
{t.auditLogs.filters.last7Days}
</button>
<button
type="button"
onClick={() => onDatePresetChange("30d")}
className={cn(
"rounded-xl px-3 py-1 font-medium transition-colors cursor-pointer border",
datePreset === "30d"
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 font-semibold shadow-xs"
: "border-border/60 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
{t.auditLogs.filters.last30Days}
</button>
<button
type="button"
onClick={() => onDatePresetChange("custom")}
className={cn(
"rounded-xl px-3 py-1 font-medium transition-colors cursor-pointer border",
datePreset === "custom"
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 font-semibold shadow-xs"
: "border-border/60 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
{t.auditLogs.filters.custom}
</button>
{/* Custom Date Range Pickers (Revealed when 'custom' is active) */}
{datePreset === "custom" && (
<div className="flex items-center gap-2 pl-1 animate-in fade-in-50 duration-200">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">{t.auditLogs.filters.startDate}:</span>
<Input
type="date"
value={startDate}
onChange={(e) => onCustomDateChange(e.target.value, endDate)}
className="h-8 w-36 rounded-xl border-border/70 text-xs px-2"
/>
</div>
<span className="text-muted-foreground">-</span>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">{t.auditLogs.filters.endDate}:</span>
<Input
type="date"
value={endDate}
onChange={(e) => onCustomDateChange(startDate, e.target.value)}
className="h-8 w-36 rounded-xl border-border/70 text-xs px-2"
/>
</div>
</div>
)}
</div>
</div>
);
}
"use client";
import React from "react";
import {
Clock,
User,
Shield,
Globe,
Monitor,
Database,
ChevronRight,
AlertCircle,
RefreshCw,
Eye,
Copy,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AuditLogItem,
getActionCategory,
getTargetResource,
} from "@/types/audit-log";
import { useLanguage } from "@/providers/language-provider";
import {
formatDate,
formatRelativeTime,
parseUserAgent,
cn,
} from "@/lib/utils";
import { TableSortHeader } from "@/components/common/table-sort-header";
import { TablePagination } from "@/components/common/table-pagination";
interface AuditLogsTableProps {
logs: AuditLogItem[];
isLoading: boolean;
isError: boolean;
onRetry: () => void;
onSelectLog: (log: AuditLogItem) => void;
sortBy?: "createdAt" | "action";
sortOrder?: "asc" | "desc";
onSort?: (field: "createdAt" | "action") => void;
page?: number;
totalPages?: number;
total?: number;
limit?: number;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
}
export function AuditLogsTable({
logs,
isLoading,
isError,
onRetry,
onSelectLog,
sortBy,
sortOrder,
onSort,
page,
totalPages,
total,
limit,
onPageChange,
onLimitChange,
}: AuditLogsTableProps) {
const { t, locale } = useLanguage();
const [copiedIp, setCopiedIp] = React.useState<string | null>(null);
const handleCopyIp = (e: React.MouseEvent, ip: string) => {
e.stopPropagation();
navigator.clipboard.writeText(ip);
setCopiedIp(ip);
setTimeout(() => setCopiedIp(null), 2000);
};
// Helper to render action badge
const renderActionBadge = (action: string) => {
const category = getActionCategory(action);
switch (category) {
case "CREATE":
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/25 px-2.5 py-0.5 text-xs font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
{action}
</span>
);
case "UPDATE":
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/25 px-2.5 py-0.5 text-xs font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
{action}
</span>
);
case "DELETE":
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/25 px-2.5 py-0.5 text-xs font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
{action}
</span>
);
case "LOGIN":
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/25 px-2.5 py-0.5 text-xs font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
{action}
</span>
);
default:
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-teal-500/10 text-teal-600 dark:text-teal-400 border border-teal-500/25 px-2.5 py-0.5 text-xs font-semibold">
<span className="h-1.5 w-1.5 rounded-full bg-teal-500" />
{action}
</span>
);
}
};
// 1. Loading Skeleton State
if (isLoading) {
return (
<div className="overflow-hidden rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm">
<div className="p-6 space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-border/50">
<div className="h-4 w-40 bg-muted/60 rounded-md animate-pulse" />
<div className="h-4 w-28 bg-muted/60 rounded-md animate-pulse" />
</div>
{[...Array(6)].map((_, i) => (
<div
key={i}
className="flex items-center justify-between gap-4 py-3.5 border-b border-border/40 last:border-0"
>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-2xl bg-muted/70 animate-pulse shrink-0" />
<div className="space-y-1.5">
<div className="h-3.5 w-32 bg-muted/70 rounded-md animate-pulse" />
<div className="h-3 w-20 bg-muted/50 rounded-md animate-pulse" />
</div>
</div>
<div className="h-6 w-28 bg-muted/60 rounded-full animate-pulse" />
<div className="hidden md:block h-6 w-24 bg-muted/50 rounded-md animate-pulse" />
<div className="hidden lg:block h-4 w-32 bg-muted/50 rounded-md animate-pulse" />
<div className="h-4 w-20 bg-muted/60 rounded-md animate-pulse" />
</div>
))}
</div>
</div>
);
}
// 2. Error State
if (isError) {
return (
<div className="rounded-3xl border border-red-500/20 bg-red-500/5 p-8 text-center backdrop-blur-sm">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-red-500/10 text-red-600 dark:text-red-400 mb-3 border border-red-500/20">
<AlertCircle className="h-6 w-6" />
</div>
<h3 className="text-base font-semibold text-foreground">
{t.auditLogs.table.errorTitle}
</h3>
<p className="mt-1 text-xs text-muted-foreground max-w-md mx-auto">
{t.auditLogs.table.errorDesc}
</p>
<Button
onClick={onRetry}
size="sm"
className="mt-4 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white font-medium cursor-pointer"
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
{t.auditLogs.table.retry}
</Button>
</div>
);
}
// 3. Empty State
if (!logs || logs.length === 0) {
return (
<div className="rounded-3xl border border-dashed border-border/80 bg-card/40 p-12 text-center backdrop-blur-sm">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/60 text-muted-foreground mb-3 border border-border/60">
<Shield className="h-7 w-7 text-emerald-500/60" />
</div>
<h3 className="text-base font-semibold text-foreground">
{t.auditLogs.table.emptyTitle}
</h3>
<p className="mt-1 text-xs text-muted-foreground max-w-md mx-auto">
{t.auditLogs.table.emptyDesc}
</p>
</div>
);
}
// 4. Success Timeline Table State
return (
<div className="overflow-hidden rounded-3xl border border-emerald-500/15 bg-card/60 shadow-sm backdrop-blur-sm transition-colors duration-200">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-foreground">
{/* Table Header */}
<thead className="border-b border-border/70 bg-muted/40 text-[11px] uppercase tracking-wider text-muted-foreground select-none">
<tr>
<th scope="col" className="px-5 py-3.5">
<TableSortHeader
label={t.auditLogs.table.timestamp}
field="createdAt"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => onSort?.(f as "createdAt" | "action")}
/>
</th>
<th scope="col" className="px-4 py-3.5 font-semibold text-muted-foreground">
{t.auditLogs.table.actor}
</th>
<th scope="col" className="px-4 py-3.5">
<TableSortHeader
label={t.auditLogs.table.action}
field="action"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => onSort?.(f as "createdAt" | "action")}
/>
</th>
<th scope="col" className="hidden md:table-cell px-4 py-3.5 font-semibold text-muted-foreground">
{t.auditLogs.table.targetResource}
</th>
<th scope="col" className="hidden lg:table-cell px-4 py-3.5 font-semibold text-muted-foreground">
{t.auditLogs.table.clientInfo}
</th>
<th scope="col" className="px-5 py-3.5 font-semibold text-right text-muted-foreground">
{t.auditLogs.table.actions}
</th>
</tr>
</thead>
{/* Table Body */}
<tbody className="divide-y divide-border/40">
{logs.map((log) => {
const resource = getTargetResource(log);
const ua = parseUserAgent(log.userAgent);
const initials = (log.user?.fullName || log.user?.email || "SYS")
.split(" ")
.map((n) => n[0])
.join("")
.substring(0, 2)
.toUpperCase();
return (
<tr
key={log.id}
onClick={() => onSelectLog(log)}
className="group hover:bg-emerald-500/[0.04] transition-colors cursor-pointer"
>
{/* Timestamp Column */}
<td className="px-5 py-4 whitespace-nowrap">
<div
className="flex items-center gap-2"
title={formatDate(log.createdAt, locale)}
>
<Clock className="h-3.5 w-3.5 text-emerald-500/70 shrink-0" />
<div className="flex flex-col">
<span className="font-semibold text-foreground hover:underline decoration-emerald-500/40">
{formatRelativeTime(log.createdAt, locale)}
</span>
<span className="text-[10px] text-muted-foreground">
{formatDate(log.createdAt, locale)}
</span>
</div>
</div>
</td>
{/* Actor / User Column */}
<td className="px-4 py-4">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500/20 to-teal-700/20 text-emerald-700 dark:text-emerald-300 font-bold text-[11px] border border-emerald-500/20 shrink-0">
{initials}
</div>
<div className="flex flex-col min-w-0">
<span className="truncate font-semibold text-foreground">
{log.user?.fullName ||
(log.user?.email ? log.user.email.split("@")[0] : t.auditLogs.table.anonymous)}
</span>
<div className="flex items-center gap-1.5 mt-0.5">
{log.user?.role && (
<span className="inline-flex items-center rounded-full bg-muted px-1.5 py-0.2 text-[9px] font-semibold text-muted-foreground border border-border/60">
{log.user.role}
</span>
)}
{log.user?.email && (
<span className="text-[10px] text-muted-foreground truncate max-w-[140px]">
{log.user.email}
</span>
)}
</div>
</div>
</div>
</td>
{/* Action Column */}
<td className="px-4 py-4 whitespace-nowrap">
{renderActionBadge(log.action)}
</td>
{/* Target Resource Column */}
<td className="hidden md:table-cell px-4 py-4 whitespace-nowrap">
<div className="inline-flex items-center gap-1.5 rounded-xl bg-muted/60 px-2.5 py-1 text-[11px] font-mono font-medium text-foreground border border-border/70">
<Database className="h-3 w-3 text-emerald-500 shrink-0" />
<span>{resource.label}</span>
</div>
</td>
{/* IP & Short Client UA Column */}
<td className="hidden lg:table-cell px-4 py-4 whitespace-nowrap">
<div className="space-y-1">
{/* IP address with click-to-copy */}
<div className="flex items-center gap-1.5 text-[11px] font-mono text-muted-foreground">
<Globe className="h-3 w-3 text-emerald-500 shrink-0" />
<span>{log.ipAddress || "127.0.0.1"}</span>
{log.ipAddress && (
<button
onClick={(e) => handleCopyIp(e, log.ipAddress!)}
className="p-0.5 hover:text-foreground transition-colors cursor-pointer"
title="Sao chép IP"
>
{copiedIp === log.ipAddress ? (
<Check className="h-2.5 w-2.5 text-emerald-500" />
) : (
<Copy className="h-2.5 w-2.5 opacity-60 hover:opacity-100" />
)}
</button>
)}
</div>
{/* Shortened User-Agent */}
<div className="flex items-center gap-1 text-[10px] text-muted-foreground/80">
<Monitor className="h-2.5 w-2.5 shrink-0" />
<span className="truncate max-w-[160px]">{ua.short}</span>
</div>
</div>
</td>
{/* Details Trigger Column */}
<td className="px-5 py-4 whitespace-nowrap text-right">
<button
onClick={() => onSelectLog(log)}
className="inline-flex items-center gap-1 rounded-xl px-2.5 py-1 text-xs font-medium text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors cursor-pointer"
>
<Eye className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
{t.auditLogs.table.viewDetails}
</span>
<ChevronRight className="h-3.5 w-3.5 group-hover:translate-x-0.5 transition-transform" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Integrated Pagination Footer */}
{typeof total === "number" && total > 0 && onPageChange && onLimitChange && (
<TablePagination
page={page || 1}
totalPages={totalPages || 1}
total={total}
limit={limit || 20}
onPageChange={onPageChange}
onLimitChange={onLimitChange}
isLoading={isLoading}
options={[10, 20, 50, 100]}
/>
)}
</div>
);
}
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { History, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/providers/language-provider";
import { useAuditLogs } from "@/hooks/use-audit-logs";
import { AuditLogItem, AuditLogQuery } from "@/types/audit-log";
import { AuditLogsFilters, DatePreset } from "./audit-logs-filters";
import { AuditLogsTable } from "./audit-logs-table";
import { AuditLogDetailDrawer } from "./audit-log-detail-drawer";
interface AuditLogsViewProps {
showHeader?: boolean;
}
export function AuditLogsView({ showHeader = true }: AuditLogsViewProps) {
const { t } = useLanguage();
// Filter States
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [action, setAction] = useState("");
const [datePreset, setDatePreset] = useState<DatePreset>("all");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [sortBy, setSortBy] = useState<"createdAt" | "action">("createdAt");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
// Drawer detail selection
const [selectedLog, setSelectedLog] = useState<AuditLogItem | null>(null);
const handleToggleSort = (column: "createdAt" | "action") => {
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("desc");
}
setPage(1);
};
// Debounce search input by 350ms
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search.trim());
setPage(1);
}, 350);
return () => clearTimeout(timer);
}, [search]);
// Calculate ISO date strings based on preset
const { queryStartDate, queryEndDate } = useMemo(() => {
if (datePreset === "all") {
return { queryStartDate: undefined, queryEndDate: undefined };
}
const now = new Date();
if (datePreset === "today") {
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
return {
queryStartDate: startOfDay.toISOString(),
queryEndDate: now.toISOString(),
};
}
if (datePreset === "7d") {
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return {
queryStartDate: sevenDaysAgo.toISOString(),
queryEndDate: now.toISOString(),
};
}
if (datePreset === "30d") {
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
return {
queryStartDate: thirtyDaysAgo.toISOString(),
queryEndDate: now.toISOString(),
};
}
if (datePreset === "custom") {
return {
queryStartDate: startDate ? new Date(startDate).toISOString() : undefined,
queryEndDate: endDate ? new Date(`${endDate}T23:59:59.999Z`).toISOString() : undefined,
};
}
return { queryStartDate: undefined, queryEndDate: undefined };
}, [datePreset, startDate, endDate]);
// Build query object
const queryParams: AuditLogQuery = useMemo(() => {
const q: AuditLogQuery = {
page,
limit,
sortBy,
order: sortOrder,
};
if (debouncedSearch) {
q.search = debouncedSearch;
}
if (action) {
q.action = action;
}
if (queryStartDate) {
q.startDate = queryStartDate;
}
if (queryEndDate) {
q.endDate = queryEndDate;
}
return q;
}, [page, limit, debouncedSearch, action, queryStartDate, queryEndDate, sortBy, sortOrder]);
// Fetch Audit Logs
const { data, isLoading, isError, isFetching, refetch } = useAuditLogs(queryParams);
const logs = data?.items || [];
const meta = data?.meta || { total: 0, page: 1, limit: 20, totalPages: 1 };
const totalLogs = meta.total;
// Reset Filters
const handleResetFilters = () => {
setSearch("");
setDebouncedSearch("");
setAction("");
setDatePreset("all");
setStartDate("");
setEndDate("");
setPage(1);
};
const hasActiveFilters = Boolean(
search || action || datePreset !== "all" || startDate || endDate
);
return (
<div className="space-y-6">
{/* Top Header Section (If enabled) */}
{showHeader && (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="space-y-1">
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
<History className="h-3.5 w-3.5" />
<span>{t.auditLogs.badge}</span>
</div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground">
{t.auditLogs.title}
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.auditLogs.subtitle}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching || isLoading}
className="rounded-2xl border-border/80 text-xs font-semibold hover:bg-emerald-500/10 hover:text-emerald-600 dark:hover:text-emerald-400 hover:border-emerald-500/30 transition-all cursor-pointer shadow-xs"
>
<RefreshCw
className={`h-3.5 w-3.5 mr-1.5 ${
isFetching || isLoading ? "animate-spin text-emerald-500" : ""
}`}
/>
<span>{t.auditLogs.refreshBtn}</span>
</Button>
</div>
</div>
)}
{/* Filters Bar Component */}
<div className="relative z-30 rounded-3xl border border-emerald-500/15 bg-card/60 p-4 sm:p-5 backdrop-blur-sm shadow-xs">
<AuditLogsFilters
search={search}
onSearchChange={setSearch}
action={action}
onActionChange={(newAction) => {
setAction(newAction);
setPage(1);
}}
datePreset={datePreset}
onDatePresetChange={(preset) => {
setDatePreset(preset);
setPage(1);
}}
startDate={startDate}
endDate={endDate}
onCustomDateChange={(s, e) => {
setStartDate(s);
setEndDate(e);
setPage(1);
}}
onReset={handleResetFilters}
hasActiveFilters={hasActiveFilters}
/>
</div>
{/* Timeline Table Component */}
<div className="relative z-10">
<AuditLogsTable
logs={logs}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
onSelectLog={(log) => setSelectedLog(log)}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleToggleSort}
page={page}
totalPages={meta.totalPages}
total={meta.total}
limit={limit}
onPageChange={(p) => setPage(p)}
onLimitChange={(l) => {
setLimit(l);
setPage(1);
}}
/>
</div>
{/* Slide-over Detail Drawer */}
<AuditLogDetailDrawer
log={selectedLog}
isOpen={Boolean(selectedLog)}
onClose={() => setSelectedLog(null)}
/>
</div>
);
}
"use client";
import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, Check, Rows3 } from "lucide-react";
import { cn } from "@/lib/utils";
export interface PageSizeSelectProps {
value: number;
onChange: (value: number) => void;
options?: number[];
direction?: "up" | "down";
className?: string;
disabled?: boolean;
unitLabel?: string;
}
const DEFAULT_OPTIONS = [10, 20, 50, 100];
export function PageSizeSelect({
value,
onChange,
options = DEFAULT_OPTIONS,
direction = "up",
className,
disabled = false,
unitLabel,
}: PageSizeSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click or Escape key
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
const handleSelect = (opt: number) => {
onChange(opt);
setIsOpen(false);
};
return (
<div
ref={containerRef}
className={cn("relative inline-block text-left", className)}
>
{/* Trigger Button - Conforming strictly to ThemeToggle pattern */}
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setIsOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label="Chọn số dòng mỗi trang"
className={cn(
"inline-flex h-8 items-center justify-between gap-2 rounded-2xl border px-2.5 text-xs font-semibold shadow-xs transition-all cursor-pointer",
isOpen
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 ring-2 ring-emerald-500/20"
: "border-border/80 bg-card text-foreground hover:bg-muted/70 hover:border-emerald-500/30",
disabled && "opacity-50 cursor-not-allowed pointer-events-none"
)}
>
<div className="flex items-center gap-1.5">
<Rows3 className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
<span className="tabular-nums font-mono">{value}</span>
{unitLabel && (
<span className="text-muted-foreground font-normal text-[11px]">
{unitLabel}
</span>
)}
</div>
<ChevronDown
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 shrink-0",
isOpen && "rotate-180 text-emerald-600 dark:text-emerald-400"
)}
/>
</button>
{/* Popup Menu - ThemeToggle Pattern */}
{isOpen && (
<div
role="listbox"
aria-label="Số dòng mỗi trang"
className={cn(
"absolute left-0 min-w-[5.5rem] rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150",
direction === "up" ? "bottom-full mb-1.5 origin-bottom-left" : "top-full mt-1.5 origin-top-left"
)}
>
<div className="space-y-0.5">
{options.map((opt) => {
const isSelected = opt === value;
return (
<button
key={opt}
role="option"
aria-selected={isSelected}
type="button"
onClick={() => handleSelect(opt)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-xl px-2.5 py-1.5 text-xs transition-colors cursor-pointer text-left font-mono",
isSelected
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20"
: "text-foreground hover:bg-muted/70 border border-transparent"
)}
>
<span>{opt}</span>
{isSelected && (
<Check className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400 shrink-0" />
)}
</button>
);
})}
</div>
</div>
)}
</div>
);
}
"use client";
import React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PageSizeSelect } from "@/components/common/page-size-select";
import { useLanguage } from "@/providers/language-provider";
import { cn } from "@/lib/utils";
export interface TablePaginationProps {
page: number;
totalPages: number;
total: number;
limit: number;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
options?: number[];
totalLabel?: string;
perPageLabel?: string;
isLoading?: boolean;
className?: string;
}
export function TablePagination({
page,
totalPages,
total,
limit,
onPageChange,
onLimitChange,
options = [10, 20, 50, 100],
totalLabel,
perPageLabel,
isLoading = false,
className,
}: TablePaginationProps) {
const { t } = useLanguage();
return (
<div
className={cn(
"flex flex-col sm:flex-row items-center justify-between gap-3 px-4 py-3 border-t border-border/70 bg-muted/20 text-xs text-muted-foreground select-none",
className
)}
>
{/* Left: Total & Page info */}
<div className="flex items-center gap-2">
<span>
{totalLabel ?? t.table.totalJobs}:{" "}
<strong className="text-foreground font-semibold font-mono">
{total.toLocaleString()}
</strong>
</span>
<span className="text-muted-foreground/60"></span>
<span>
{t.table.page}{" "}
<strong className="text-foreground font-semibold font-mono">
{page}
</strong>{" "}
{t.table.of}{" "}
<strong className="text-foreground font-semibold font-mono">
{totalPages || 1}
</strong>
</span>
</div>
{/* Right: Page size select & Prev/Next buttons */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{perPageLabel ?? t.table.perPage}:</span>
<PageSizeSelect
value={limit}
onChange={onLimitChange}
options={options}
direction="up"
disabled={isLoading}
/>
</div>
{/* Prev / Next Page Buttons */}
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
disabled={page <= 1 || isLoading}
onClick={() => onPageChange(Math.max(1, page - 1))}
className="h-7 w-7 p-0 rounded-xl border border-border/80 bg-background hover:bg-muted text-muted-foreground hover:text-foreground shadow-xs transition-colors disabled:opacity-40 cursor-pointer"
title={t.table.page}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages || isLoading}
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
className="h-7 w-7 p-0 rounded-xl border border-border/80 bg-background hover:bg-muted text-muted-foreground hover:text-foreground shadow-xs transition-colors disabled:opacity-40 cursor-pointer"
title={t.table.page}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
);
}
"use client";
import React from "react";
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface TableSortHeaderProps {
label: string;
field: string;
currentSortField?: string;
currentSortOrder?: "asc" | "desc";
onSort: (field: string) => void;
className?: string;
}
export function TableSortHeader({
label,
field,
currentSortField,
currentSortOrder = "desc",
onSort,
className,
}: TableSortHeaderProps) {
const isActive = currentSortField === field;
return (
<button
type="button"
onClick={() => onSort(field)}
className={cn(
"inline-flex items-center gap-1.5 cursor-pointer select-none transition-colors group hover:text-foreground text-left text-xs font-semibold focus:outline-none",
isActive
? "text-emerald-600 dark:text-emerald-400 font-bold"
: "text-muted-foreground",
className
)}
>
<span>{label}</span>
{isActive ? (
currentSortOrder === "asc" ? (
<ArrowUp className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400 shrink-0 transition-transform" />
) : (
<ArrowDown className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400 shrink-0 transition-transform" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50 group-hover:text-muted-foreground shrink-0 transition-colors" />
)}
</button>
);
}
......@@ -23,6 +23,8 @@ import { Input } from "@/components/ui/input";
import { formatDate, formatNumber } from "@/lib/utils";
import { CrawlJob, CrawlJobStatus } from "@/types/crawl-job";
import { useLanguage } from "@/providers/language-provider";
import { TableSortHeader } from "@/components/common/table-sort-header";
import { TablePagination } from "@/components/common/table-pagination";
import {
AlertCircle,
ArrowUpDown,
......@@ -276,36 +278,36 @@ export function CrawlerTaskTable() {
</TableHead>
{/* Column 2: Start URL / Domain */}
<TableHead
onClick={() => handleToggleSort("startUrl")}
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5"
>
<div className="flex items-center gap-1.5">
<span>{t.table.name}</span>
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
</div>
<TableHead className="py-3.5">
<TableSortHeader
label={t.table.name}
field="startUrl"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => handleToggleSort(f as typeof sortBy)}
/>
</TableHead>
{/* Column 3: Status & Created Date (Sorts by createdAt) */}
<TableHead
onClick={() => handleToggleSort("createdAt")}
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5"
>
<div className="flex items-center gap-1.5">
<span>{t.table.status}</span>
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
</div>
<TableHead className="py-3.5">
<TableSortHeader
label={t.table.status}
field="createdAt"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => handleToggleSort(f as typeof sortBy)}
/>
</TableHead>
{/* Column 4: Crawl Progress */}
<TableHead
onClick={() => handleToggleSort("totalPages")}
className="cursor-pointer hover:text-foreground font-semibold text-xs py-3.5 min-w-[170px] max-[767px]:hidden"
>
<div className="flex items-center gap-1.5">
<span>{t.table.progress}</span>
<ArrowUpDown className="h-3 w-3 text-muted-foreground" />
</div>
<TableHead className="py-3.5 min-w-[170px] max-[767px]:hidden">
<TableSortHeader
label={t.table.progress}
field="totalPages"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => handleToggleSort(f as typeof sortBy)}
/>
</TableHead>
{/* Column 5: Extracted Items */}
......@@ -532,59 +534,18 @@ export function CrawlerTaskTable() {
</Table>
{/* 6. Server-Side Pagination Bar */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-4 py-3 border-t border-border/70 bg-muted/20 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span>
{t.table.totalJobs}: <strong className="text-foreground">{total}</strong>
</span>
<span></span>
<span>
{t.table.page} <strong className="text-foreground">{page}</strong> {t.table.of}{" "}
<strong className="text-foreground">{totalPages}</strong>
</span>
</div>
<div className="flex items-center gap-3">
{/* Page size select */}
<div className="flex items-center gap-1.5">
<span>{t.table.perPage}:</span>
<select
value={limit}
onChange={(e) => {
setLimit(Number(e.target.value));
setPage(1);
}}
className="rounded-xl border border-border/80 bg-background px-2 py-1 text-xs text-foreground cursor-pointer"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
{/* Prev / Next Page Buttons */}
<div className="flex items-center gap-1">
<Button
size="sm"
variant="outline"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
className="h-7 w-7 p-0 rounded-xl border border-border/80 bg-background hover:bg-muted text-muted-foreground hover:text-foreground shadow-xs transition-colors disabled:opacity-40"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
className="h-7 w-7 p-0 rounded-xl border border-border/80 bg-background hover:bg-muted text-muted-foreground hover:text-foreground shadow-xs transition-colors disabled:opacity-40"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<TablePagination
page={page}
totalPages={totalPages}
total={total}
limit={limit}
onPageChange={(p) => setPage(p)}
onLimitChange={(l) => {
setLimit(l);
setPage(1);
}}
options={[10, 20, 50, 100]}
/>
</div>
)}
</div>
......
......@@ -16,7 +16,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
export function ServiceHealthBar() {
const { readiness, metrics, isLoading, isFetching, refetch } = useServiceHealth();
const { readiness, metrics, liveness, isLoading, isFetching, refetch } = useServiceHealth();
const { t } = useLanguage();
const dbCheck = readiness?.checks?.database;
......@@ -26,7 +26,11 @@ export function ServiceHealthBar() {
const isRedisUp = redisCheck?.status === "up";
const isDegraded = redisCheck?.status === "degraded";
const isOverallHealthy = readiness?.status === "ready" && isDbUp && (isRedisUp || isDegraded);
const isNodeHealthy = liveness ? liveness.status === "ok" : true;
const nodeUptimeSeconds = liveness?.uptimeSeconds ?? metrics?.process?.uptimeSeconds ?? 0;
const isOverallHealthy =
readiness?.status === "ready" && isDbUp && (isRedisUp || isDegraded) && isNodeHealthy;
// Queue metrics from /health/metrics
const crawlQueue = typeof metrics?.queues?.crawl === "object" ? metrics.queues.crawl : null;
......@@ -34,7 +38,7 @@ export function ServiceHealthBar() {
const activeJobs = crawlQueue?.active ?? 0;
// Process uptime & RAM
const uptimeSeconds = metrics?.process?.uptimeSeconds ?? 0;
const uptimeSeconds = nodeUptimeSeconds;
const formatUptime = (sec: number) => {
if (sec < 60) return `${sec}s`;
const mins = Math.floor(sec / 60);
......
......@@ -10,6 +10,7 @@ import {
Terminal,
Users,
Shield,
History,
} from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
import { useAuth } from "@/hooks/use-auth";
......@@ -27,6 +28,7 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
const pathname = usePathname();
const canManageUsers = hasPermission("users.read") || role === "ADMIN";
const canManageRoles = hasPermission("roles.read") || role === "ADMIN";
const canReadAuditLogs = hasPermission("audit_logs.read") || role === "ADMIN";
// Counts for badges
const { data: apiKeys = [] } = useApiKeysList();
......@@ -45,6 +47,9 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
pathname === "/settings/developer";
const isWebhooksTab = pathname.startsWith("/settings/developer/webhooks");
const isDocsTab = pathname.startsWith("/settings/developer/docs");
const isAuditLogsTab =
pathname.startsWith("/settings/developer/audit-logs") ||
pathname.startsWith("/audit-logs");
return (
<div className="min-h-screen bg-background pb-16">
......@@ -151,6 +156,21 @@ export function DeveloperShell({ children }: DeveloperShellProps) {
<Terminal className="h-4 w-4" />
<span>{t.developer.tabs.docs}</span>
</Link>
{/* 5. Tab Nhật Ký Kiểm Toán (Audit Logs) */}
{canReadAuditLogs && (
<Link
href="/settings/developer/audit-logs"
className={`flex items-center gap-2 px-4 py-3 text-xs font-bold border-b-2 transition-all cursor-pointer whitespace-nowrap ${
isAuditLogsTab
? "border-emerald-500 text-emerald-600 dark:text-emerald-400"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<History className="h-4 w-4" />
<span>{t.nav.auditLogs}</span>
</Link>
)}
</div>
{/* Tab Page Content */}
......
......@@ -19,6 +19,8 @@ import { Button } from "@/components/ui/button";
import { UserItem } from "@/types/user";
import { useLanguage } from "@/providers/language-provider";
import { formatDate } from "@/lib/utils";
import { TableSortHeader } from "@/components/common/table-sort-header";
import { TablePagination } from "@/components/common/table-pagination";
interface UserTableProps {
users: UserItem[];
......@@ -32,6 +34,15 @@ interface UserTableProps {
onCreateNew: () => void;
currentUserId?: string;
isTogglingStatus?: boolean;
sortBy?: string;
sortOrder?: "asc" | "desc";
onSort?: (field: string) => void;
page?: number;
totalPages?: number;
total?: number;
limit?: number;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
}
export function UserTable({
......@@ -46,6 +57,15 @@ export function UserTable({
onCreateNew,
currentUserId,
isTogglingStatus,
sortBy,
sortOrder,
onSort,
page,
totalPages,
total,
limit,
onPageChange,
onLimitChange,
}: UserTableProps) {
const { t, locale } = useLanguage();
......@@ -221,27 +241,52 @@ export function UserTable({
{/* Table Header */}
<thead className="border-b border-border/70 bg-muted/40 text-[11px] uppercase tracking-wider text-muted-foreground select-none">
<tr>
<th scope="col" className="px-5 py-3.5 font-semibold">
{t.users.table.user}
<th scope="col" className="px-5 py-3.5">
<TableSortHeader
label={t.users.table.user}
field="fullName"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => onSort?.(f)}
/>
</th>
<th scope="col" className="px-4 py-3.5 font-semibold">
{t.users.table.email}
<th scope="col" className="px-4 py-3.5">
<TableSortHeader
label={t.users.table.email}
field="email"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => onSort?.(f)}
/>
</th>
<th scope="col" className="px-4 py-3.5 font-semibold">
<th scope="col" className="px-4 py-3.5 font-semibold text-muted-foreground">
{t.users.table.roles}
</th>
<th scope="col" className="px-4 py-3.5 font-semibold text-center">
{t.users.table.status}
<th scope="col" className="px-4 py-3.5 text-center">
<TableSortHeader
label={t.users.table.status}
field="isActive"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => onSort?.(f)}
className="justify-center"
/>
</th>
<th
scope="col"
className="hidden lg:table-cell px-4 py-3.5 font-semibold"
className="hidden lg:table-cell px-4 py-3.5"
>
{t.users.table.createdAt}
<TableSortHeader
label={t.users.table.createdAt}
field="createdAt"
currentSortField={sortBy}
currentSortOrder={sortOrder}
onSort={(f) => onSort?.(f)}
/>
</th>
<th
scope="col"
className="px-5 py-3.5 font-semibold text-right"
className="px-5 py-3.5 font-semibold text-right text-muted-foreground"
>
{t.users.table.actions}
</th>
......@@ -406,6 +451,20 @@ export function UserTable({
</tbody>
</table>
</div>
{/* Integrated Pagination Footer */}
{typeof total === "number" && total > 0 && onPageChange && onLimitChange && (
<TablePagination
page={page || 1}
totalPages={totalPages || 1}
total={total}
limit={limit || 20}
onPageChange={onPageChange}
onLimitChange={onLimitChange}
isLoading={isLoading}
options={[10, 20, 50, 100]}
/>
)}
</div>
);
}
......@@ -13,8 +13,6 @@ import {
UserX,
Shield,
RotateCcw,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
......@@ -46,6 +44,18 @@ export function UsersManagementView({
const [roleFilter, setRoleFilter] = useState<string>("ALL");
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(20);
const [sortBy, setSortBy] = useState<string>("createdAt");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const handleToggleSort = (column: string) => {
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("desc");
}
setPage(1);
};
// Dropdown open states
const [isStatusDropdownOpen, setIsStatusDropdownOpen] = useState(false);
......@@ -114,8 +124,10 @@ export function UsersManagementView({
role: roleFilter === "ALL" ? undefined : roleFilter,
page,
limit,
sortBy,
order: sortOrder,
};
}, [debouncedSearch, statusFilter, roleFilter, page, limit]);
}, [debouncedSearch, statusFilter, roleFilter, page, limit, sortBy, sortOrder]);
// Fetch users with TanStack Query
const {
......@@ -126,7 +138,24 @@ export function UsersManagementView({
isFetching,
} = useUsersList(queryParams);
const users: UserItem[] = usersData?.items || [];
const rawUsers: UserItem[] = usersData?.items || [];
const users = useMemo(() => {
if (!sortBy) return rawUsers;
return [...rawUsers].sort((a, b) => {
let aVal: any = a[sortBy as keyof UserItem];
let bVal: any = b[sortBy as keyof UserItem];
if (sortBy === "fullName") {
aVal = a.fullName || a.email;
bVal = b.fullName || b.email;
}
if (aVal === bVal) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
const cmp = aVal < bVal ? -1 : 1;
return sortOrder === "asc" ? cmp : -cmp;
});
}, [rawUsers, sortBy, sortOrder]);
const meta = usersData?.meta || {
total: 0,
page: 1,
......@@ -311,7 +340,7 @@ export function UsersManagementView({
</div>
{/* Filter & Search Toolbar */}
<div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center justify-between">
<div className="relative z-20 flex flex-col lg:flex-row gap-3 items-stretch lg:items-center justify-between">
{/* Search Bar */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
......@@ -452,70 +481,33 @@ export function UsersManagementView({
</div>
{/* Users Table */}
<UserTable
users={users}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
onEdit={(u) => setEditingUser(u)}
onManageRoles={(u) => setRolesUser(u)}
onDelete={(u) => setDeletingUser(u)}
onToggleStatus={handleToggleStatus}
onCreateNew={() => setIsCreateModalOpen(true)}
currentUserId={currentUser?.id}
isTogglingStatus={updateMutation.isPending}
/>
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-1">
<p className="text-xs text-muted-foreground">
{t.users.pagination.showing}{" "}
<span className="font-semibold text-foreground">
{(page - 1) * limit + 1}
</span>{" "}
-{" "}
<span className="font-semibold text-foreground">
{Math.min(page * limit, meta.total)}
</span>{" "}
{t.users.pagination.of}{" "}
<span className="font-semibold text-foreground">{meta.total}</span>{" "}
{t.users.pagination.users}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || isFetching}
className="rounded-xl h-8 px-3 text-xs cursor-pointer"
>
<ChevronLeft className="h-3.5 w-3.5 mr-1" />
{t.users.pagination.prev}
</Button>
<div className="flex items-center gap-1 text-xs font-medium px-2">
<span className="text-emerald-600 dark:text-emerald-400 font-bold">
{meta.page}
</span>
<span className="text-muted-foreground">/</span>
<span className="text-muted-foreground">{meta.totalPages}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))}
disabled={page >= meta.totalPages || isFetching}
className="rounded-xl h-8 px-3 text-xs cursor-pointer"
>
{t.users.pagination.next}
<ChevronRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
</div>
)}
<div className="relative z-10">
<UserTable
users={users}
isLoading={isLoading}
isError={isError}
onRetry={() => refetch()}
onEdit={(u) => setEditingUser(u)}
onManageRoles={(u) => setRolesUser(u)}
onDelete={(u) => setDeletingUser(u)}
onToggleStatus={handleToggleStatus}
onCreateNew={() => setIsCreateModalOpen(true)}
currentUserId={currentUser?.id}
isTogglingStatus={updateMutation.isPending}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleToggleSort}
page={page}
totalPages={meta.totalPages}
total={meta.total}
limit={limit}
onPageChange={(p) => setPage(p)}
onLimitChange={(l) => {
setLimit(l);
setPage(1);
}}
/>
</div>
{/* Modals & Dialogs */}
{isCreateModalOpen && (
......
import { useQuery } from "@tanstack/react-query";
import { auditLogService } from "@/services/audit-log.service";
import { AuditLogQuery } from "@/types/audit-log";
export const AUDIT_LOGS_QUERY_KEYS = {
all: ["audit-logs"] as const,
list: (query?: AuditLogQuery) => ["audit-logs", "list", query] as const,
};
export function useAuditLogs(query?: AuditLogQuery) {
return useQuery({
queryKey: AUDIT_LOGS_QUERY_KEYS.list(query),
queryFn: () => auditLogService.getAuditLogs(query),
placeholderData: (previousData) => previousData,
staleTime: 10000,
retry: 1,
});
}
......@@ -4,6 +4,7 @@ import { systemService } from "@/services/system.service";
export const SYSTEM_QUERY_KEYS = {
readiness: ["system", "readiness"] as const,
metrics: ["system", "metrics"] as const,
liveness: ["system", "liveness"] as const,
health: ["system", "health"] as const,
};
......@@ -27,19 +28,35 @@ export function useServiceMetrics() {
});
}
export function useServiceLiveness() {
return useQuery({
queryKey: SYSTEM_QUERY_KEYS.liveness,
queryFn: () => systemService.getLiveness(),
refetchInterval: 15000,
staleTime: 10000,
retry: 1,
});
}
export function useServiceHealth() {
const readinessQuery = useServiceReadiness();
const metricsQuery = useServiceMetrics();
const livenessQuery = useServiceLiveness();
return {
readiness: readinessQuery.data,
metrics: metricsQuery.data,
isLoading: readinessQuery.isLoading || metricsQuery.isLoading,
isFetching: readinessQuery.isFetching || metricsQuery.isFetching,
isError: readinessQuery.isError || metricsQuery.isError,
error: readinessQuery.error || metricsQuery.error,
liveness: livenessQuery.data,
isLoading: readinessQuery.isLoading || metricsQuery.isLoading || livenessQuery.isLoading,
isFetching: readinessQuery.isFetching || metricsQuery.isFetching || livenessQuery.isFetching,
isError: readinessQuery.isError || metricsQuery.isError || livenessQuery.isError,
error: readinessQuery.error || metricsQuery.error || livenessQuery.error,
refetch: async () => {
await Promise.all([readinessQuery.refetch(), metricsQuery.refetch()]);
await Promise.all([
readinessQuery.refetch(),
metricsQuery.refetch(),
livenessQuery.refetch(),
]);
},
};
}
......@@ -11,7 +11,11 @@ export const translations = {
templates: "Mẫu Bóc Tách",
exports: "Xuất Dữ Liệu",
storage: "Kho Dữ Liệu",
admin: "Quản Trị",
adminMenu: "Menu Quản Trị",
users: "Quản Lý Người Dùng",
roles: "Vai Trò & Phân Quyền",
auditLogs: "Nhật Ký Hệ Thống",
statusActive: "Động cơ Crawler Đang Chạy",
reload: "Tải lại trang",
copyright: "Data Crawler © 2026 - Code by @hnihTyoB",
......@@ -155,6 +159,10 @@ export const translations = {
refreshBtn: "Làm mới trạng thái",
refreshing: "Đang kiểm tra...",
liveBadge: "Trực tiếp",
livenessHealthy: "Tiến trình Node.js ổn định",
livenessDegraded: "Tiến trình Node.js suy giảm hiệu năng",
livenessTitle: "Tiến trình Node.js",
nodeVersion: "Phiên bản",
},
// Task Table
table: {
......@@ -750,6 +758,10 @@ export const translations = {
namePlaceholder: "Nhập họ và tên của bạn",
emailLabel: "Địa chỉ Email",
emailVerified: "Đã xác thực",
userIdLabel: "Mã định danh",
joinedDateLabel: "Ngày gia nhập",
copyId: "Sao chép",
copiedId: "Đã sao chép!",
roleLabel: "Vai trò người dùng",
saveBtn: "Lưu Thay Đổi",
saving: "Đang lưu...",
......@@ -772,6 +784,8 @@ export const translations = {
newPlaceholder: "••••••••",
confirmLabel: "Xác nhận mật khẩu mới",
confirmPlaceholder: "••••••••",
showPassword: "Hiện mật khẩu",
hidePassword: "Ẩn mật khẩu",
submitBtn: "Cập Nhật Mật Khẩu",
submitting: "Đang cập nhật...",
successMsg: "Đổi mật khẩu thành công! Các phiên đăng nhập khác đã được thu hồi.",
......@@ -1035,6 +1049,99 @@ export const translations = {
delete: "Xóa vai trò",
},
},
// Audit Logs
auditLogs: {
title: "Nhật Ký Kiểm Toán Hệ Thống",
subtitle: "Theo dõi toàn bộ các hoạt động, thay đổi dữ liệu và truy cập an ninh theo thời gian thực",
badge: "Kiểm Toán và Giám Sát",
refreshBtn: "Làm mới",
totalLogs: "Tổng số nhật ký",
createActions: "Hoạt động tạo mới",
updateActions: "Hoạt động cập nhật",
deleteActions: "Hoạt động xóa và cảnh báo",
filters: {
searchPlaceholder: "Tìm theo User ID, Email, IP hoặc hành động...",
dateRange: "Khoảng thời gian",
allTime: "Tất cả thời gian",
today: "Hôm nay",
last7Days: "7 ngày qua",
last30Days: "30 ngày qua",
custom: "Tùy chỉnh",
startDate: "Từ ngày",
endDate: "Đến ngày",
actionFilter: "Loại hành động",
allActions: "Tất cả hành động",
moduleFilter: "Phân hệ tác động",
allModules: "Tất cả phân hệ",
reset: "Đặt lại bộ lọc",
filterCount: "bộ lọc đang áp dụng",
},
table: {
timestamp: "Thời gian",
actor: "Người thực hiện",
action: "Hành động",
targetResource: "Tài nguyên tác động",
clientInfo: "Địa chỉ IP và Thiết bị",
actions: "Thao tác",
viewDetails: "Xem chi tiết",
anonymous: "Khách ẩn danh / Hệ thống",
unknownUser: "Người dùng không rõ",
noResource: "Hệ thống chung",
loading: "Đang tải dữ liệu nhật ký kiểm toán...",
emptyTitle: "Không tìm thấy nhật ký kiểm toán",
emptyDesc: "Không có bản ghi nào phù hợp với điều kiện tìm kiếm hoặc bộ lọc hiện tại của bạn.",
errorTitle: "Không thể tải nhật ký kiểm toán",
errorDesc: "Đã xảy ra lỗi khi lấy dữ liệu từ máy chủ. Vui lòng kiểm tra lại quyền hạn hoặc kết nối.",
retry: "Thử lại",
pagination: {
showing: "Hiển thị",
to: "đến",
of: "trong tổng số",
results: "bản ghi",
prev: "Trước",
next: "Sau",
page: "Trang",
pageSize: "Số dòng mỗi trang",
},
},
categories: {
create: "Tạo mới",
update: "Cập nhật",
delete: "Xóa và Hủy",
login: "Xác thực",
other: "Khác",
},
drawer: {
title: "Chi Tiết Nhật Ký Kiểm Toán",
subtitle: "Xem thông tin chi tiết phiên làm việc, dữ liệu gửi lên và thay đổi hệ thống",
metadata: "Thông tin ngữ cảnh",
logId: "Mã nhật ký",
timestamp: "Thời gian ghi nhận",
actorInfo: "Người thực hiện",
userId: "Mã người dùng",
userEmail: "Địa chỉ email",
userRole: "Vai trò người dùng",
ipAddress: "Địa chỉ IP",
userAgent: "Tác nhân người dùng đầy đủ",
parsedClient: "Trình duyệt và Hệ điều hành",
actionType: "Hành động thực hiện",
targetResource: "Tài nguyên tác động",
status: "Trạng thái",
statusSuccess: "Thành công",
statusFailed: "Thất bại",
errorAlert: "Thông Báo Lỗi",
errorDesc: "Thao tác đã phát sinh lỗi hoặc bị chặn bởi chính sách an toàn của hệ thống.",
diffTitle: "Thay Đổi Dữ Liệu Trước và Sau",
diffBefore: "Dữ liệu trước khi sửa",
diffAfter: "Dữ liệu sau khi sửa",
modifiedFields: "Các trường đã thay đổi",
payloadTitle: "Dữ Liệu Yêu Cầu Gửi Lên",
copyJson: "Sao chép dữ liệu",
copied: "Đã sao chép thành công",
noPayload: "Không có dữ liệu chi tiết được ghi nhận cho tác vụ này.",
close: "Đóng cửa sổ",
},
},
},
en: {
// Navigation & Common
......@@ -1046,7 +1153,11 @@ export const translations = {
templates: "Extraction Templates",
exports: "Exports Center",
storage: "Data Lake",
users: "Users Management",
admin: "Administration",
adminMenu: "Admin Menu",
users: "User Management",
roles: "Roles & Permissions",
auditLogs: "System Audit Logs",
statusActive: "Crawler Engine Active",
reload: "Reload page",
copyright: "Data Crawler © 2026 - Code by @hnihTyoB",
......@@ -1190,6 +1301,10 @@ export const translations = {
refreshBtn: "Refresh Status",
refreshing: "Checking...",
liveBadge: "Live",
livenessHealthy: "Node.js Process Healthy",
livenessDegraded: "Node.js Process Degraded",
livenessTitle: "Node.js Process",
nodeVersion: "Version",
},
// Task Table
table: {
......@@ -1785,6 +1900,10 @@ export const translations = {
namePlaceholder: "Enter your full name",
emailLabel: "Email Address",
emailVerified: "Verified",
userIdLabel: "User ID",
joinedDateLabel: "Joined Date",
copyId: "Copy",
copiedId: "Copied!",
roleLabel: "User Role",
saveBtn: "Save Changes",
saving: "Saving...",
......@@ -1807,6 +1926,8 @@ export const translations = {
newPlaceholder: "••••••••",
confirmLabel: "Confirm New Password",
confirmPlaceholder: "••••••••",
showPassword: "Show password",
hidePassword: "Hide password",
submitBtn: "Update Password",
submitting: "Updating...",
successMsg: "Password changed successfully! Other active sessions have been revoked.",
......@@ -2070,6 +2191,99 @@ export const translations = {
delete: "Delete Role",
},
},
// Audit Logs
auditLogs: {
title: "System Audit Logs",
subtitle: "Track all operations, data modifications and security events across the platform in real-time",
badge: "Audit & Compliance",
refreshBtn: "Refresh",
totalLogs: "Total Logs",
createActions: "Create Operations",
updateActions: "Update Operations",
deleteActions: "Delete & Alert Events",
filters: {
searchPlaceholder: "Search by User ID, Email, IP or action...",
dateRange: "Time Range",
allTime: "All Time",
today: "Today",
last7Days: "Last 7 Days",
last30Days: "Last 30 Days",
custom: "Custom",
startDate: "From Date",
endDate: "To Date",
actionFilter: "Action Type",
allActions: "All Actions",
moduleFilter: "Target Module",
allModules: "All Modules",
reset: "Reset Filters",
filterCount: "filters active",
},
table: {
timestamp: "Timestamp",
actor: "Actor",
action: "Action",
targetResource: "Target Resource",
clientInfo: "IP & Device",
actions: "Details",
viewDetails: "View Details",
anonymous: "Anonymous / System",
unknownUser: "Unknown User",
noResource: "General System",
loading: "Loading audit logs...",
emptyTitle: "No Audit Logs Found",
emptyDesc: "No audit log entries match your current search query or filter criteria.",
errorTitle: "Failed to Load Audit Logs",
errorDesc: "An error occurred while fetching audit log records. Please check permissions or network connection.",
retry: "Retry",
pagination: {
showing: "Showing",
to: "to",
of: "of",
results: "entries",
prev: "Prev",
next: "Next",
page: "Page",
pageSize: "Rows per page",
},
},
categories: {
create: "Create",
update: "Update",
delete: "Delete",
login: "Auth",
other: "Other",
},
drawer: {
title: "Audit Log Details",
subtitle: "Inspect session context, request payload and state transitions",
metadata: "Contextual Metadata",
logId: "Log ID",
timestamp: "Recorded At",
actorInfo: "Performing User",
userId: "User ID",
userEmail: "Email Address",
userRole: "Assigned Role",
ipAddress: "IP Address",
userAgent: "Full User-Agent",
parsedClient: "Browser & Operating System",
actionType: "Executed Action",
targetResource: "Target Resource",
status: "Status",
statusSuccess: "Success",
statusFailed: "Failed",
errorAlert: "Error Details",
errorDesc: "This operation encountered an error or was rejected by security policies.",
diffTitle: "Data Changes (Before & After)",
diffBefore: "Original Data (Before)",
diffAfter: "Updated Data (After)",
modifiedFields: "Modified Fields",
payloadTitle: "Request Payload JSON",
copyJson: "Copy JSON",
copied: "Copied!",
noPayload: "No detailed payload captured for this audit event.",
close: "Close Drawer",
},
},
},
};
......
......@@ -29,3 +29,105 @@ export function formatNumber(value: number | string): string {
if (isNaN(num)) return String(value);
return new Intl.NumberFormat("en-US").format(num);
}
export function formatRelativeTime(
date: string | Date | number,
locale: string = "vi"
): string {
const now = Date.now();
const target = new Date(date).getTime();
if (isNaN(target)) return "";
const diffSec = Math.floor((now - target) / 1000);
const isEn = locale === "en";
if (diffSec < 45) {
return isEn ? "Just now" : "Vừa xong";
}
if (diffSec < 90) {
return isEn ? "1 minute ago" : "1 phút trước";
}
if (diffSec < 3600) {
const mins = Math.floor(diffSec / 60);
return isEn ? `${mins} minutes ago` : `${mins} phút trước`;
}
if (diffSec < 7200) {
return isEn ? "1 hour ago" : "1 giờ trước";
}
if (diffSec < 86400) {
const hours = Math.floor(diffSec / 3600);
return isEn ? `${hours} hours ago` : `${hours} giờ trước`;
}
if (diffSec < 172800) {
return isEn ? "Yesterday" : "Hôm qua";
}
if (diffSec < 604800) {
const days = Math.floor(diffSec / 86400);
return isEn ? `${days} days ago` : `${days} ngày trước`;
}
return formatDate(date, locale);
}
export interface ParsedUserAgent {
browser: string;
os: string;
short: string;
full: string;
}
export function parseUserAgent(uaString?: string | null): ParsedUserAgent {
if (!uaString) {
return {
browser: "Unknown",
os: "Unknown",
short: "Unknown",
full: "Unknown",
};
}
const ua = uaString;
// Browser detection
let browser = "Browser";
if (ua.includes("Edg/")) {
browser = "Edge";
} else if (ua.includes("Chrome/") && !ua.includes("Edg/")) {
browser = "Chrome";
} else if (ua.includes("Firefox/")) {
browser = "Firefox";
} else if (ua.includes("Safari/") && !ua.includes("Chrome/")) {
browser = "Safari";
} else if (ua.includes("PostmanRuntime")) {
browser = "Postman";
} else if (ua.toLowerCase().includes("curl")) {
browser = "cURL";
} else if (ua.toLowerCase().includes("axios")) {
browser = "Axios";
} else if (ua.toLowerCase().includes("node")) {
browser = "Node.js";
} else if (ua.toLowerCase().includes("bot") || ua.toLowerCase().includes("spider") || ua.toLowerCase().includes("crawler")) {
browser = "Bot/Crawler";
}
// OS detection
let os = "System";
if (ua.includes("Windows NT 10.0") || ua.includes("Windows")) {
os = "Windows";
} else if (ua.includes("Macintosh") || ua.includes("Mac OS X")) {
os = "macOS";
} else if (ua.includes("iPhone") || ua.includes("iPad")) {
os = "iOS";
} else if (ua.includes("Android")) {
os = "Android";
} else if (ua.includes("Linux") && !ua.includes("Android")) {
os = "Linux";
}
return {
browser,
os,
short: `${browser}${os}`,
full: uaString,
};
}
......@@ -18,6 +18,7 @@ const ADMIN_ROUTES = [
"/roles",
"/settings/developer/users",
"/settings/developer/roles",
"/settings/developer/audit-logs",
];
// Open public pages that do NOT require login (e.g. 403 forbidden, 404 not-found)
......
import apiClient from "@/lib/api-client";
import { AuditLogQuery, AuditLogResponse } from "@/types/audit-log";
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
export const auditLogService = {
/**
* Lấy danh sách nhật ký kiểm toán với các bộ lọc phân trang
* GET /audit-logs (qua BFF Proxy)
*/
async getAuditLogs(query?: AuditLogQuery): Promise<AuditLogResponse> {
const response = await apiClient.get<ApiResponse<AuditLogResponse>>("/audit-logs", {
params: query,
});
if (response.data && response.data.data) {
return response.data.data;
}
return (response.data as unknown) as AuditLogResponse;
},
};
import apiClient, { ApiError } from "@/lib/api-client";
import { SystemMetricsResponse, SystemReadinessResponse } from "@/types/system";
import { SystemLivenessResponse, SystemMetricsResponse, SystemReadinessResponse } from "@/types/system";
import axios from "axios";
export const systemService = {
......@@ -66,4 +66,28 @@ export const systemService = {
};
}
},
/**
* Lấy trạng thái sống còn (Liveness) của tiến trình Node.js máy chủ
* GET /health/liveness (qua BFF Proxy)
*/
async getLiveness(): Promise<SystemLivenessResponse> {
try {
const response = await apiClient.get<SystemLivenessResponse>("/health/liveness");
return response.data;
} catch (error: unknown) {
if (error instanceof ApiError && error.data && typeof error.data === "object" && "status" in error.data) {
return error.data as SystemLivenessResponse;
}
if (axios.isAxiosError(error) && error.response?.data && typeof error.response.data === "object" && "status" in error.response.data) {
return error.response.data as SystemLivenessResponse;
}
return {
status: "degraded",
uptimeSeconds: 0,
timestamp: new Date().toISOString(),
nodeVersion: "unknown",
};
}
},
};
export type AuditActionCategory = "CREATE" | "UPDATE" | "DELETE" | "LOGIN" | "OTHER";
export interface AuditLogUser {
id: string;
email: string;
fullName: string | null;
role: string | null;
}
export interface AuditLogDetails {
jobId?: string;
startUrl?: string;
mode?: string;
userId?: string;
targetUserId?: string;
roleId?: string;
slug?: string;
templateId?: string;
apiKeyId?: string;
keyPrefix?: string;
webhookConfigId?: string;
exportId?: string;
updatedFields?: string[];
before?: Record<string, unknown>;
after?: Record<string, unknown>;
changes?: {
before?: Record<string, unknown>;
after?: Record<string, unknown>;
};
error?: string;
message?: string;
payload?: Record<string, unknown>;
status?: string;
[key: string]: unknown;
}
export interface AuditLogItem {
id: string;
userId: string | null;
action: string;
details: AuditLogDetails | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: string;
user: AuditLogUser | null;
}
export interface AuditLogQuery {
userId?: string;
action?: string;
startDate?: string;
endDate?: string;
search?: string;
sortBy?: "createdAt" | "action";
order?: "asc" | "desc";
page?: number;
limit?: number;
}
export interface AuditLogMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface AuditLogResponse {
items: AuditLogItem[];
meta: AuditLogMeta;
}
export function getActionCategory(action: string): AuditActionCategory {
const act = action.toUpperCase();
if (act.includes("CREATE") || act.includes("ASSIGN")) return "CREATE";
if (act.includes("UPDATE") || act.includes("CHANGE") || act.includes("REDELIVER")) return "UPDATE";
if (
act.includes("DELETE") ||
act.includes("CANCEL") ||
act.includes("REVOKE") ||
act.includes("DEACTIVATE") ||
act.includes("BLOCKED") ||
act.includes("ATTEMPT")
) {
return "DELETE";
}
if (
act.includes("LOGIN") ||
act.includes("LOGOUT") ||
act.includes("REGISTER") ||
act.includes("PASSWORD") ||
act.includes("VERIF")
) {
return "LOGIN";
}
return "OTHER";
}
export function getTargetResource(log: AuditLogItem): {
type: string;
label: string;
id?: string;
} {
const d = log.details || {};
if (d.jobId) return { type: "Job", label: `Job #${String(d.jobId).slice(0, 8)}`, id: String(d.jobId) };
if (d.targetUserId) return { type: "User", label: `User #${String(d.targetUserId).slice(0, 8)}`, id: String(d.targetUserId) };
if (d.roleId || d.slug) return { type: "Role", label: `Role ${d.slug || "#" + String(d.roleId).slice(0, 8)}`, id: String(d.roleId || d.slug) };
if (d.templateId) return { type: "Template", label: `Template #${String(d.templateId).slice(0, 8)}`, id: String(d.templateId) };
if (d.apiKeyId || d.keyPrefix) return { type: "API Key", label: `API Key ${d.keyPrefix ? d.keyPrefix + "..." : "#" + String(d.apiKeyId).slice(0, 8)}`, id: String(d.apiKeyId) };
if (d.webhookConfigId) return { type: "Webhook", label: `Webhook #${String(d.webhookConfigId).slice(0, 8)}`, id: String(d.webhookConfigId) };
if (d.exportId) return { type: "Export", label: `Export #${String(d.exportId).slice(0, 8)}`, id: String(d.exportId) };
if (d.userId && d.userId !== log.userId) return { type: "User", label: `User #${String(d.userId).slice(0, 8)}`, id: String(d.userId) };
// Fallback to action keyword
const act = log.action.toUpperCase();
if (act.includes("JOB")) return { type: "Job", label: "Job" };
if (act.includes("USER")) return { type: "User", label: "User" };
if (act.includes("ROLE") || act.includes("PERMISSION")) return { type: "Role", label: "Role" };
if (act.includes("KEY")) return { type: "API Key", label: "API Key" };
if (act.includes("WEBHOOK")) return { type: "Webhook", label: "Webhook" };
if (act.includes("EXPORT")) return { type: "Export", label: "Export" };
return { type: "System", label: "System" };
}
......@@ -48,3 +48,10 @@ export interface SystemMetricsResponse {
webhook: QueueMetricsResult;
};
}
export interface SystemLivenessResponse {
status: "ok" | string;
uptimeSeconds: number;
timestamp: string;
nodeVersion: string;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment