feat : add audit logs
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { connection } from "next/server";
|
||||
import { ClipboardListIcon } from "lucide-react";
|
||||
|
||||
import { AdminHeader } from "@/components/admin/admin-header";
|
||||
import { AuditLogFilters } from "@/components/admin/audit-log-filters";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
AUDIT_ACTION_LABELS,
|
||||
AUDIT_TARGET_LABELS,
|
||||
AUDIT_TARGET_TYPES,
|
||||
isAuditTargetType,
|
||||
listAuditActors,
|
||||
listAuditLogs,
|
||||
type AuditAction,
|
||||
} from "@/lib/audit-log";
|
||||
import { getAdminSession } from "@/lib/auth/server";
|
||||
|
||||
function pageHref(
|
||||
page: number,
|
||||
actorId?: string,
|
||||
targetType?: string,
|
||||
) {
|
||||
const query = new URLSearchParams({ page: String(page) });
|
||||
if (actorId) query.set("actor", actorId);
|
||||
if (targetType) query.set("type", targetType);
|
||||
return `/admin/activity?${query}`;
|
||||
}
|
||||
|
||||
function eventSummary(metadata: Record<string, string | number | boolean | null>) {
|
||||
const label = metadata.sectionTitle
|
||||
?? metadata.guideName
|
||||
?? metadata.alias
|
||||
?? metadata.fileName
|
||||
?? metadata.accountLabel;
|
||||
if (typeof label === "string") return label;
|
||||
if (typeof metadata.error === "string") return metadata.error;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default async function AuditLogPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
await connection();
|
||||
if (!(await getAdminSession())) redirect("/admin/login");
|
||||
const query = await searchParams;
|
||||
const rawPage = Array.isArray(query.page) ? query.page[0] : query.page;
|
||||
const parsedPage = Number(rawPage);
|
||||
const page = Number.isSafeInteger(parsedPage) && parsedPage > 0
|
||||
? parsedPage
|
||||
: 1;
|
||||
const rawActorId = Array.isArray(query.actor) ? query.actor[0] : query.actor;
|
||||
const actorId = rawActorId && rawActorId.length <= 128
|
||||
? rawActorId
|
||||
: undefined;
|
||||
const rawTargetType = Array.isArray(query.type) ? query.type[0] : query.type;
|
||||
const targetType = isAuditTargetType(rawTargetType)
|
||||
? rawTargetType
|
||||
: undefined;
|
||||
const [{ events, pageCount, total }, actors] = await Promise.all([
|
||||
listAuditLogs({ page, pageSize: 50, actorId, targetType }),
|
||||
listAuditActors(),
|
||||
]);
|
||||
const dateTime = new Intl.DateTimeFormat("th-TH", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
timeZone: "Asia/Bangkok",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<AdminHeader />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-4 sm:p-6 lg:p-8">
|
||||
<header>
|
||||
<p className="text-sm font-medium text-primary">Admin audit trail</p>
|
||||
<h1 className="font-heading text-3xl font-semibold">Activity</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
ประวัติการเปลี่ยนแปลงที่บันทึกสำเร็จทั้งหมด {total.toLocaleString("th-TH")} รายการ
|
||||
</p>
|
||||
</header>
|
||||
<AuditLogFilters
|
||||
key={`${actorId ?? "all"}:${targetType ?? "all"}`}
|
||||
actors={actors}
|
||||
targetTypes={AUDIT_TARGET_TYPES.map((value) => ({
|
||||
value,
|
||||
label: AUDIT_TARGET_LABELS[value],
|
||||
}))}
|
||||
initialActorId={actorId}
|
||||
initialTargetType={targetType}
|
||||
/>
|
||||
{events.length ? (
|
||||
<>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>เวลา</TableHead>
|
||||
<TableHead>ผู้ดูแล</TableHead>
|
||||
<TableHead>การทำงาน</TableHead>
|
||||
<TableHead>เป้าหมาย</TableHead>
|
||||
<TableHead>รายละเอียด</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{events.map((event) => {
|
||||
const action = event.action as AuditAction;
|
||||
const summary = eventSummary(event.metadata);
|
||||
return (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell>{dateTime.format(event.createdAt)}</TableCell>
|
||||
<TableCell>{event.actorLabel}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{AUDIT_ACTION_LABELS[action] ?? event.action}</span>
|
||||
{event.metadata.overwrite === true ? (
|
||||
<Badge variant="outline">Overwrite</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge variant="secondary">
|
||||
{isAuditTargetType(event.targetType)
|
||||
? AUDIT_TARGET_LABELS[event.targetType]
|
||||
: event.targetType}
|
||||
</Badge>
|
||||
<span className="max-w-48 truncate font-mono text-xs text-muted-foreground">
|
||||
{event.targetId}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex max-w-80 flex-col gap-1">
|
||||
{summary ? <span className="truncate">{summary}</span> : null}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{event.scope ? `${event.scope} · ` : ""}
|
||||
{event.resultingVersion
|
||||
? `v${event.resultingVersion}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{pageCount > 1 ? (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
{page > 1 ? (
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={pageHref(page - 1, actorId, targetType)}
|
||||
text="ก่อนหน้า"
|
||||
/>
|
||||
</PaginationItem>
|
||||
) : null}
|
||||
{page < pageCount ? (
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={pageHref(page + 1, actorId, targetType)}
|
||||
text="ถัดไป"
|
||||
/>
|
||||
</PaginationItem>
|
||||
) : null}
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Empty className="border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<ClipboardListIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>ยังไม่มีกิจกรรม</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
ลองเปลี่ยนตัวกรอง หรือกลับมาหลังจากมีการแก้ไขข้อมูล
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user