Merge pull request 'audit logs' (#4) from el-ui into prod
Some checks failed
Deploy Yaltopia Tickets Admin / deploy (push) Failing after 23s
Some checks failed
Deploy Yaltopia Tickets Admin / deploy (push) Failing after 23s
Reviewed-on: #4
This commit is contained in:
commit
4c5bd59084
|
|
@ -7,7 +7,6 @@ import {
|
||||||
Search,
|
Search,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Filter,
|
|
||||||
Terminal,
|
Terminal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { auditService, type AuditLog } from "@/services";
|
import { auditService, type AuditLog } from "@/services";
|
||||||
|
|
@ -18,26 +17,44 @@ export default function ActivityLogPage() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [limit] = useState(15);
|
const [limit] = useState(15);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [filter, setFilter] = useState<"action" | "userId">("action");
|
||||||
|
|
||||||
const { data: auditData, isLoading } = useQuery({
|
const { data: auditData, isLoading } = useQuery({
|
||||||
queryKey: ["activity-log", page, limit, search],
|
queryKey: ["activity-log", page, limit, search, filter],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params: Record<string, string | number> = { page, limit };
|
const params: Record<string, string | number> = {
|
||||||
if (search) params.search = search;
|
page,
|
||||||
return await auditService.getAuditLogs(params);
|
limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (search.trim()) {
|
||||||
|
params.search = search.trim();
|
||||||
|
params.filter = filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return auditService.getAuditLogs(params);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getActionColor = (action: string) => {
|
const getActionColor = (action: string) => {
|
||||||
const act = action.toUpperCase();
|
const act = action.toUpperCase();
|
||||||
if (act.includes("CREATE"))
|
|
||||||
|
if (act.includes("CREATE")) {
|
||||||
return "text-blue-600 bg-blue-50 border-blue-100";
|
return "text-blue-600 bg-blue-50 border-blue-100";
|
||||||
if (act.includes("UPDATE"))
|
}
|
||||||
|
|
||||||
|
if (act.includes("UPDATE")) {
|
||||||
return "text-emerald-600 bg-emerald-50 border-emerald-100";
|
return "text-emerald-600 bg-emerald-50 border-emerald-100";
|
||||||
if (act.includes("DELETE"))
|
}
|
||||||
|
|
||||||
|
if (act.includes("DELETE")) {
|
||||||
return "text-rose-600 bg-rose-50 border-rose-100";
|
return "text-rose-600 bg-rose-50 border-rose-100";
|
||||||
if (act.includes("LOGIN"))
|
}
|
||||||
|
|
||||||
|
if (act.includes("LOGIN")) {
|
||||||
return "text-purple-600 bg-purple-50 border-purple-100";
|
return "text-purple-600 bg-purple-50 border-purple-100";
|
||||||
|
}
|
||||||
|
|
||||||
return "text-slate-600 bg-slate-50 border-slate-100";
|
return "text-slate-600 bg-slate-50 border-slate-100";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -48,13 +65,11 @@ export default function ActivityLogPage() {
|
||||||
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
||||||
Activity Log
|
Activity Log
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-gray-500 mt-1">
|
<p className="text-gray-500 mt-1">
|
||||||
Audit trail of all administrative actions.
|
Audit trail of all administrative actions.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* View only access: Export button removed */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border shadow-none rounded-none">
|
<Card className="border shadow-none rounded-none">
|
||||||
|
|
@ -62,25 +77,36 @@ export default function ActivityLogPage() {
|
||||||
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
||||||
System Audit
|
System Audit
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative w-64">
|
<select
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setFilter(e.target.value as "action" | "userId");
|
||||||
|
}}
|
||||||
|
className="h-9 px-3 border border-gray-200 text-xs bg-white"
|
||||||
|
>
|
||||||
|
<option value="action">Action</option>
|
||||||
|
<option value="userId">User ID</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="relative w-72">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
||||||
placeholder="Search activity or user..."
|
placeholder={`Search by ${filter}...`}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setPage(1);
|
||||||
|
setSearch(e.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 rounded-none border-gray-200"
|
|
||||||
>
|
|
||||||
<Filter className="w-4 h-4 mr-2" /> Filter
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
|
|
@ -89,35 +115,36 @@ export default function ActivityLogPage() {
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||||
Action
|
Action
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||||
User ID
|
User
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||||
Resource
|
Detail
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
||||||
IP Address
|
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
||||||
Timestamp
|
Timestamp
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={5}
|
colSpan={4}
|
||||||
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
|
||||||
>
|
>
|
||||||
Synchronizing audit records...
|
Synchronizing audit records...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : auditData?.data && auditData.data.length > 0 ? (
|
) : auditData?.data?.length ? (
|
||||||
auditData.data.map((log: AuditLog) => (
|
auditData.data.map((log: AuditLog) => (
|
||||||
<tr
|
<tr
|
||||||
key={log.id}
|
key={log.id}
|
||||||
className="hover:bg-gray-50 transition-colors group"
|
className="hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<span
|
<span
|
||||||
|
|
@ -129,25 +156,42 @@ export default function ActivityLogPage() {
|
||||||
{log.action}
|
{log.action}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm font-bold text-gray-900 tracking-tighter">
|
|
||||||
{log.userId || "SYSTEM"}
|
<td className="px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{log.user
|
||||||
|
? `${log.user.firstName ?? ""} ${
|
||||||
|
log.user.lastName ?? ""
|
||||||
|
}`.trim() || "Unknown User"
|
||||||
|
: "SYSTEM"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{log.user?.email ?? log.userId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase flex items-center gap-1.5 mt-4">
|
|
||||||
<Terminal className="w-3 h-3" /> {log.resourceType}:{" "}
|
<td className="px-6 py-4">
|
||||||
{log.resourceId.substring(0, 8)}...
|
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<Terminal className="w-4 h-4 text-gray-400 shrink-0" />
|
||||||
|
<span>{log.detail}</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-xs font-mono text-gray-500">
|
|
||||||
{log.ipAddress || "--"}
|
<td className="px-6 py-4 text-right text-xs text-gray-500 whitespace-nowrap">
|
||||||
</td>
|
{format(
|
||||||
<td className="px-6 py-4 text-right text-xs text-gray-500">
|
new Date(log.createdAt),
|
||||||
{format(new Date(log.timestamp), "MMM dd, HH:mm:ss")}
|
"MMM dd, yyyy HH:mm:ss",
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={5}
|
colSpan={4}
|
||||||
className="px-6 py-20 text-center text-gray-400 italic"
|
className="px-6 py-20 text-center text-gray-400 italic"
|
||||||
>
|
>
|
||||||
No activity logs recorded.
|
No activity logs recorded.
|
||||||
|
|
@ -158,30 +202,31 @@ export default function ActivityLogPage() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{auditData && auditData.totalPages > 1 && (
|
|
||||||
|
{auditData?.meta && auditData.meta.totalPages > 1 && (
|
||||||
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
|
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
|
||||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
||||||
Page {auditData.page} of {auditData.totalPages} ({auditData.total}{" "}
|
Page {auditData.meta.page} of {auditData.meta.totalPages} (
|
||||||
records)
|
{auditData.meta.total} records)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded-none"
|
className="h-8 w-8 rounded-none"
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={page === 1}
|
disabled={!auditData.meta.hasPreviousPage}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded-none"
|
className="h-8 w-8 rounded-none"
|
||||||
onClick={() =>
|
onClick={() => setPage((p) => p + 1)}
|
||||||
setPage((p) => Math.min(auditData.totalPages, p + 1))
|
disabled={!auditData.meta.hasNextPage}
|
||||||
}
|
|
||||||
disabled={page === auditData.totalPages}
|
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,135 @@
|
||||||
import apiClient from './api/client'
|
import apiClient from "./api/client";
|
||||||
|
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
id: string
|
id: string;
|
||||||
userId: string
|
action: string;
|
||||||
action: string
|
detail: string;
|
||||||
resourceType: string
|
userId: string | null;
|
||||||
resourceId: string
|
createdAt: string;
|
||||||
changes?: Record<string, unknown>
|
|
||||||
ipAddress: string
|
user?: {
|
||||||
userAgent: string
|
id: string;
|
||||||
timestamp: string
|
email: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogResponse {
|
||||||
|
data: AuditLog[];
|
||||||
|
|
||||||
|
meta: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetAuditLogsParams {
|
export interface GetAuditLogsParams {
|
||||||
page?: number
|
page?: number;
|
||||||
limit?: number
|
limit?: number;
|
||||||
userId?: string
|
|
||||||
action?: string
|
search?: string;
|
||||||
resourceType?: string
|
|
||||||
resourceId?: string
|
// new API filter
|
||||||
startDate?: string
|
filter?: "userId" | "action";
|
||||||
endDate?: string
|
|
||||||
search?: string
|
userId?: string;
|
||||||
|
action?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditStats {
|
export interface AuditStats {
|
||||||
totalActions: number
|
totalActions: number;
|
||||||
uniqueUsers: number
|
uniqueUsers: number;
|
||||||
topActions: Array<{ action: string; count: number }>
|
topActions: Array<{
|
||||||
topUsers: Array<{ userId: string; count: number }>
|
action: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
topUsers: Array<{
|
||||||
|
userId: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuditService {
|
class AuditService {
|
||||||
/**
|
async getAuditLogs(
|
||||||
* Get audit logs with pagination and filters
|
params?: GetAuditLogsParams,
|
||||||
*/
|
): Promise<AuditLogResponse> {
|
||||||
async getAuditLogs(params?: GetAuditLogsParams): Promise<{
|
const response = await apiClient.get<AuditLogResponse>(
|
||||||
data: AuditLog[]
|
"/activity-logs",
|
||||||
total: number
|
{
|
||||||
page: number
|
|
||||||
limit: number
|
|
||||||
totalPages: number
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.get('/admin/audit/logs', { params })
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get audit log by ID
|
|
||||||
*/
|
|
||||||
async getAuditLog(id: string): Promise<AuditLog> {
|
|
||||||
const response = await apiClient.get<AuditLog>(`/admin/audit/logs/${id}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user audit activity
|
|
||||||
*/
|
|
||||||
async getUserAuditActivity(userId: string, days: number = 30): Promise<AuditLog[]> {
|
|
||||||
const response = await apiClient.get<AuditLog[]>(`/admin/audit/users/${userId}`, {
|
|
||||||
params: { days },
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get resource history
|
|
||||||
*/
|
|
||||||
async getResourceHistory(type: string, id: string): Promise<AuditLog[]> {
|
|
||||||
const response = await apiClient.get<AuditLog[]>(`/admin/audit/resource/${type}/${id}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get audit statistics
|
|
||||||
*/
|
|
||||||
async getAuditStats(startDate?: string, endDate?: string): Promise<AuditStats> {
|
|
||||||
const response = await apiClient.get<AuditStats>('/admin/audit/stats', {
|
|
||||||
params: { startDate, endDate },
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Export audit logs
|
|
||||||
*/
|
|
||||||
async exportAuditLogs(params?: {
|
|
||||||
format?: 'csv' | 'json'
|
|
||||||
startDate?: string
|
|
||||||
endDate?: string
|
|
||||||
}): Promise<Blob> {
|
|
||||||
const response = await apiClient.get('/admin/audit/export', {
|
|
||||||
params,
|
params,
|
||||||
responseType: 'blob',
|
},
|
||||||
})
|
);
|
||||||
return response.data
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuditLog(id: string): Promise<AuditLog> {
|
||||||
|
const response = await apiClient.get<AuditLog>(
|
||||||
|
`/activity-logs/${id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserAuditActivity(
|
||||||
|
userId: string,
|
||||||
|
days = 30,
|
||||||
|
): Promise<AuditLog[]> {
|
||||||
|
const response = await apiClient.get<AuditLog[]>(
|
||||||
|
`/admin/audit/users/${userId}`,
|
||||||
|
{
|
||||||
|
params: { days },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResourceHistory(
|
||||||
|
type: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<AuditLog[]> {
|
||||||
|
const response = await apiClient.get<AuditLog[]>(
|
||||||
|
`/admin/audit/resource/${type}/${id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuditStats(
|
||||||
|
startDate?: string,
|
||||||
|
endDate?: string,
|
||||||
|
): Promise<AuditStats> {
|
||||||
|
const response = await apiClient.get<AuditStats>(
|
||||||
|
"/admin/audit/stats",
|
||||||
|
{
|
||||||
|
params: { startDate, endDate },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportAuditLogs(params?: {
|
||||||
|
format?: "csv" | "json";
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}): Promise<Blob> {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
"/admin/audit/export",
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
responseType: "blob",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const auditService = new AuditService()
|
export const auditService = new AuditService();
|
||||||
Loading…
Reference in New Issue
Block a user