Merge pull request 'audit logs' (#4) from el-ui into prod
Some checks failed
Deploy Yaltopia Tickets Admin / deploy (push) Failing after 23s

Reviewed-on: #4
This commit is contained in:
Brook-Tewabe-Yaltopia 2026-06-12 17:02:35 +03:00
commit 4c5bd59084
2 changed files with 203 additions and 124 deletions

View File

@ -7,7 +7,6 @@ import {
Search,
ChevronLeft,
ChevronRight,
Filter,
Terminal,
} from "lucide-react";
import { auditService, type AuditLog } from "@/services";
@ -18,26 +17,44 @@ export default function ActivityLogPage() {
const [page, setPage] = useState(1);
const [limit] = useState(15);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<"action" | "userId">("action");
const { data: auditData, isLoading } = useQuery({
queryKey: ["activity-log", page, limit, search],
queryKey: ["activity-log", page, limit, search, filter],
queryFn: async () => {
const params: Record<string, string | number> = { page, limit };
if (search) params.search = search;
return await auditService.getAuditLogs(params);
const params: Record<string, string | number> = {
page,
limit,
};
if (search.trim()) {
params.search = search.trim();
params.filter = filter;
}
return auditService.getAuditLogs(params);
},
});
const getActionColor = (action: string) => {
const act = action.toUpperCase();
if (act.includes("CREATE"))
if (act.includes("CREATE")) {
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";
if (act.includes("DELETE"))
}
if (act.includes("DELETE")) {
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-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">
Activity Log
</h1>
<p className="text-gray-500 mt-1">
Audit trail of all administrative actions.
</p>
</div>
<div className="flex items-center gap-2">
{/* View only access: Export button removed */}
</div>
</div>
<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">
System Audit
</CardTitle>
<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" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search activity or user..."
placeholder={`Search by ${filter}...`}
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => {
setPage(1);
setSearch(e.target.value);
}}
/>
</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>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<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">
Action
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
User ID
User
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Resource
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
IP Address
Detail
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Timestamp
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={5}
colSpan={4}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Synchronizing audit records...
</td>
</tr>
) : auditData?.data && auditData.data.length > 0 ? (
) : auditData?.data?.length ? (
auditData.data.map((log: AuditLog) => (
<tr
key={log.id}
className="hover:bg-gray-50 transition-colors group"
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4">
<span
@ -129,25 +156,42 @@ export default function ActivityLogPage() {
{log.action}
</span>
</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 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}:{" "}
{log.resourceId.substring(0, 8)}...
<td className="px-6 py-4">
<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 className="px-6 py-4 text-xs font-mono text-gray-500">
{log.ipAddress || "--"}
</td>
<td className="px-6 py-4 text-right text-xs text-gray-500">
{format(new Date(log.timestamp), "MMM dd, HH:mm:ss")}
<td className="px-6 py-4 text-right text-xs text-gray-500 whitespace-nowrap">
{format(
new Date(log.createdAt),
"MMM dd, yyyy HH:mm:ss",
)}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
colSpan={4}
className="px-6 py-20 text-center text-gray-400 italic"
>
No activity logs recorded.
@ -158,30 +202,31 @@ export default function ActivityLogPage() {
</table>
</div>
</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">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Page {auditData.page} of {auditData.totalPages} ({auditData.total}{" "}
records)
Page {auditData.meta.page} of {auditData.meta.totalPages} (
{auditData.meta.total} records)
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
disabled={!auditData.meta.hasPreviousPage}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() =>
setPage((p) => Math.min(auditData.totalPages, p + 1))
}
disabled={page === auditData.totalPages}
onClick={() => setPage((p) => p + 1)}
disabled={!auditData.meta.hasNextPage}
>
<ChevronRight className="w-4 h-4" />
</Button>

View File

@ -1,101 +1,135 @@
import apiClient from './api/client'
import apiClient from "./api/client";
export interface AuditLog {
id: string
userId: string
action: string
resourceType: string
resourceId: string
changes?: Record<string, unknown>
ipAddress: string
userAgent: string
timestamp: string
id: string;
action: string;
detail: string;
userId: string | null;
createdAt: string;
user?: {
id: 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 {
page?: number
limit?: number
userId?: string
action?: string
resourceType?: string
resourceId?: string
startDate?: string
endDate?: string
search?: string
page?: number;
limit?: number;
search?: string;
// new API filter
filter?: "userId" | "action";
userId?: string;
action?: string;
}
export interface AuditStats {
totalActions: number
uniqueUsers: number
topActions: Array<{ action: string; count: number }>
topUsers: Array<{ userId: string; count: number }>
totalActions: number;
uniqueUsers: number;
topActions: Array<{
action: string;
count: number;
}>;
topUsers: Array<{
userId: string;
count: number;
}>;
}
class AuditService {
/**
* Get audit logs with pagination and filters
*/
async getAuditLogs(params?: GetAuditLogsParams): Promise<{
data: AuditLog[]
total: number
page: number
limit: number
totalPages: number
}> {
const response = await apiClient.get('/admin/audit/logs', { params })
return response.data
async getAuditLogs(
params?: GetAuditLogsParams,
): Promise<AuditLogResponse> {
const response = await apiClient.get<AuditLogResponse>(
"/activity-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
const response = await apiClient.get<AuditLog>(
`/activity-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
async getUserAuditActivity(
userId: string,
days = 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
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
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
format?: "csv" | "json";
startDate?: string;
endDate?: string;
}): Promise<Blob> {
const response = await apiClient.get('/admin/audit/export', {
params,
responseType: 'blob',
})
return response.data
const response = await apiClient.get(
"/admin/audit/export",
{
params,
responseType: "blob",
},
);
return response.data;
}
}
export const auditService = new AuditService()
export const auditService = new AuditService();