diff --git a/app/admin/actions.ts b/app/admin/actions.ts index ca5e17e..0b830cf 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -9,6 +9,7 @@ import { deleteGlossaryAlias, } from "@/lib/glossary-repository"; import { requireAdmin } from "@/lib/auth/server"; +import { auditActor } from "@/lib/audit-log"; import { publicGuideSections } from "@/lib/guides/public-sections"; import { publicationIssues } from "@/lib/guides/publication"; import { getAdminGuideContentById } from "@/lib/guides/queries"; @@ -47,10 +48,11 @@ type GuideActionResult = type CreateGuideActionResult = Exclude; type SaveIntent = { overwrite?: boolean }; -function overwriteDetails(admin: Awaited>, intent?: SaveIntent) { - return intent?.overwrite - ? { operation: "overwrite" as const, actorName: (admin.user.name?.trim() || admin.user.email).slice(0, 100) } - : undefined; +function mutationContext( + admin: Awaited>, + intent?: SaveIntent, +) { + return { actor: auditActor(admin.user), overwrite: intent?.overwrite }; } async function runGuideAction(action: () => Promise<{ version?: number }>): Promise { @@ -69,9 +71,9 @@ async function runGuideAction(action: () => Promise<{ version?: number }>): Prom } export async function createStructuredGuide(input: unknown): Promise { - await requireAdmin(); + const admin = await requireAdmin(); try { - const guide = await createGuide(input); + const guide = await createGuide(input, mutationContext(admin)); return { status: "saved", version: guide.version, location: `/admin/${guide.characterKey}` }; } catch (cause) { return { status: "error", message: cause instanceof Error ? cause.message : "สร้าง Guide ไม่สำเร็จ" }; @@ -80,27 +82,27 @@ export async function createStructuredGuide(input: unknown): Promise saveWeapons(guideId, input, overwriteDetails(admin, intent))); + return runGuideAction(() => saveWeapons(guideId, input, mutationContext(admin, intent))); } export async function saveGuideOverview(guideId: string, input: unknown, intent?: SaveIntent) { const admin = await requireAdmin(); - return runGuideAction(() => updateGuideBasics(guideId, input, overwriteDetails(admin, intent))); + return runGuideAction(() => updateGuideBasics(guideId, input, mutationContext(admin, intent))); } export async function saveArtifactSection(guideId: string, input: unknown, intent?: SaveIntent) { const admin = await requireAdmin(); - return runGuideAction(() => saveArtifacts(guideId, input, overwriteDetails(admin, intent))); + return runGuideAction(() => saveArtifacts(guideId, input, mutationContext(admin, intent))); } export async function saveConstellationSection(guideId: string, input: unknown, intent?: SaveIntent) { const admin = await requireAdmin(); - return runGuideAction(() => saveConstellations(guideId, input, overwriteDetails(admin, intent))); + return runGuideAction(() => saveConstellations(guideId, input, mutationContext(admin, intent))); } export async function saveTeamSection(guideId: string, input: unknown, intent?: SaveIntent) { const admin = await requireAdmin(); - return runGuideAction(() => saveTeams(guideId, input, overwriteDetails(admin, intent))); + return runGuideAction(() => saveTeams(guideId, input, mutationContext(admin, intent))); } export async function listTeamImportGuides(currentGuideId: string) { @@ -129,9 +131,13 @@ export async function loadTeamImportGuide(guideId: string) { } export async function createGuideExtra(guideId: string, input: unknown) { - await requireAdmin(); + const admin = await requireAdmin(); try { - const section = await createExtraSection(z.string().uuid().parse(guideId), input); + const section = await createExtraSection( + z.string().uuid().parse(guideId), + input, + mutationContext(admin), + ); updateTag(pageCacheTag(guideId)); return { status: "saved" as const, @@ -160,18 +166,19 @@ export async function saveGuideExtra( const parsedGuideId = z.string().uuid().parse(guideId); const parsedSectionId = z.string().uuid().parse(sectionId); const result = await runGuideAction(() => - saveExtraSection(parsedGuideId, parsedSectionId, input, overwriteDetails(admin, intent)), + saveExtraSection(parsedGuideId, parsedSectionId, input, mutationContext(admin, intent)), ); if (result.status === "saved") updateTag(pageCacheTag(guideId)); return result; } export async function deleteGuideExtra(guideId: string, sectionId: string) { - await requireAdmin(); + const admin = await requireAdmin(); const result = await runGuideAction(() => deleteExtraSection( z.string().uuid().parse(guideId), z.string().uuid().parse(sectionId), + mutationContext(admin), ), ); if (result.status === "saved") updateTag(pageCacheTag(guideId)); @@ -179,36 +186,40 @@ export async function deleteGuideExtra(guideId: string, sectionId: string) { } export async function reorderGuideExtras(guideId: string, input: unknown) { - await requireAdmin(); + const admin = await requireAdmin(); const result = await runGuideAction(() => - reorderExtraSections(z.string().uuid().parse(guideId), input), + reorderExtraSections( + z.string().uuid().parse(guideId), + input, + mutationContext(admin), + ), ); if (result.status === "saved") updateTag(pageCacheTag(guideId)); return result; } export async function moveGuideToTrash(guideId: string) { - await requireAdmin(); - return runGuideAction(() => trashGuide(guideId)); + const admin = await requireAdmin(); + return runGuideAction(() => trashGuide(guideId, mutationContext(admin))); } export async function restoreStructuredGuide(guideId: string) { - await requireAdmin(); - return runGuideAction(() => restoreGuide(guideId)); + const admin = await requireAdmin(); + return runGuideAction(() => restoreGuide(guideId, mutationContext(admin))); } export async function setStructuredGuideVisibility( guideId: string, input: unknown, ) { - await requireAdmin(); + const admin = await requireAdmin(); const parsed = z.strictObject({ isPublic: z.boolean(), expectedVersion: z.number().int().positive(), }).safeParse(input); if (!parsed.success) return { status: "error" as const, message: "ข้อมูลการเผยแพร่ไม่ถูกต้อง" }; if (!parsed.data.isPublic) { - return runGuideAction(() => setGuideVisibility(guideId, parsed.data)); + return runGuideAction(() => setGuideVisibility(guideId, parsed.data, mutationContext(admin))); } const guide = await getAdminGuideContentById(guideId); @@ -223,18 +234,21 @@ export async function setStructuredGuideVisibility( if (issues.length) { return { status: "error" as const, message: issues.join(" • "), issues }; } - return runGuideAction(() => setGuideVisibility(guideId, parsed.data)); + return runGuideAction(() => setGuideVisibility(guideId, parsed.data, mutationContext(admin))); } export async function deleteStructuredGuide(guideId: string) { - await requireAdmin(); - return runGuideAction(() => permanentlyDeleteGuide(guideId)); + const admin = await requireAdmin(); + return runGuideAction(() => permanentlyDeleteGuide(guideId, mutationContext(admin))); } export async function startCatalogSync() { - await requireAdmin(); + const admin = await requireAdmin(); try { - return { status: "started" as const, job: await startCatalogSyncJob() }; + return { + status: "started" as const, + job: await startCatalogSyncJob(auditActor(admin.user)), + }; } catch (cause) { return { status: "error" as const, message: cause instanceof Error ? cause.message : "เริ่ม Sync ไม่สำเร็จ" }; } @@ -246,14 +260,14 @@ export async function getCatalogSyncProgress(jobId: string) { } export async function cancelCatalogSync(jobId: string) { - await requireAdmin(); - return cancelCatalogSyncJob(jobId); + const admin = await requireAdmin(); + return cancelCatalogSyncJob(jobId, auditActor(admin.user)); } export async function addGlossaryAlias(input: unknown) { - await requireAdmin(); + const admin = await requireAdmin(); try { - const alias = await createGlossaryAlias(input); + const alias = await createGlossaryAlias(input, auditActor(admin.user)); updateTag(GLOSSARY_CACHE_TAG); return { status: "saved" as const, alias }; } catch (cause) { @@ -269,9 +283,12 @@ export async function addGlossaryAlias(input: unknown) { } export async function removeGlossaryAlias(id: string) { - await requireAdmin(); + const admin = await requireAdmin(); try { - await deleteGlossaryAlias(z.string().uuid().parse(id)); + await deleteGlossaryAlias( + z.string().uuid().parse(id), + auditActor(admin.user), + ); updateTag(GLOSSARY_CACHE_TAG); return { status: "saved" as const }; } catch (cause) { diff --git a/app/admin/activity/page.tsx b/app/admin/activity/page.tsx new file mode 100644 index 0000000..1e1cdee --- /dev/null +++ b/app/admin/activity/page.tsx @@ -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) { + 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>; +}) { + 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 ( +
+ +
+
+

Admin audit trail

+

Activity

+

+ ประวัติการเปลี่ยนแปลงที่บันทึกสำเร็จทั้งหมด {total.toLocaleString("th-TH")} รายการ +

+
+ ({ + value, + label: AUDIT_TARGET_LABELS[value], + }))} + initialActorId={actorId} + initialTargetType={targetType} + /> + {events.length ? ( + <> +
+ + + + เวลา + ผู้ดูแล + การทำงาน + เป้าหมาย + รายละเอียด + + + + {events.map((event) => { + const action = event.action as AuditAction; + const summary = eventSummary(event.metadata); + return ( + + {dateTime.format(event.createdAt)} + {event.actorLabel} + +
+ {AUDIT_ACTION_LABELS[action] ?? event.action} + {event.metadata.overwrite === true ? ( + Overwrite + ) : null} +
+
+ +
+ + {isAuditTargetType(event.targetType) + ? AUDIT_TARGET_LABELS[event.targetType] + : event.targetType} + + + {event.targetId} + +
+
+ +
+ {summary ? {summary} : null} + + {event.scope ? `${event.scope} · ` : ""} + {event.resultingVersion + ? `v${event.resultingVersion}` + : ""} + +
+
+
+ ); + })} +
+
+
+ {pageCount > 1 ? ( + + + {page > 1 ? ( + + + + ) : null} + {page < pageCount ? ( + + + + ) : null} + + + ) : null} + + ) : ( + + + + + + ยังไม่มีกิจกรรม + + ลองเปลี่ยนตัวกรอง หรือกลับมาหลังจากมีการแก้ไขข้อมูล + + + + )} +
+
+ ); +} diff --git a/app/admin/register/actions.ts b/app/admin/register/actions.ts index 50b6a02..7b1973b 100644 --- a/app/admin/register/actions.ts +++ b/app/admin/register/actions.ts @@ -8,7 +8,10 @@ import { z } from "zod"; import { getDb } from "@/db"; import { users } from "@/db/schema"; import { getAuth, requireAdmin } from "@/lib/auth/server"; -import { securityLog } from "@/lib/security/http"; +import { + auditActor, + writeAuditLogBestEffort, +} from "@/lib/audit-log"; export interface CreateAccountState { status: "idle" | "error" | "success"; @@ -45,7 +48,7 @@ export async function createAccount( } try { - await getAuth().api.createUser({ + const created = await getAuth().api.createUser({ body: { name: parsed.data.name, email: parsed.data.email, @@ -55,12 +58,19 @@ export async function createAccount( }, headers: await headers(), }); + await writeAuditLogBestEffort(auditActor(admin.user), { + action: "admin_account.created", + targetType: "admin_account", + targetId: created.user.id, + metadata: { + accountLabel: created.user.name?.trim() || created.user.email, + }, + }); } catch { return { status: "error", message: "สร้างบัญชีไม่สำเร็จ โปรดลองอีกครั้ง" }; } revalidatePath("/admin/register"); - securityLog("account-created", { actorId: admin.user.id }); return { status: "success", message: `สร้างบัญชี ${parsed.data.email} แล้ว` }; } @@ -75,16 +85,30 @@ export async function removeAccount( return { status: "error", message: "ไม่สามารถลบบัญชีที่กำลังใช้งานอยู่" }; } + const [target] = await getDb() + .select({ name: users.name, email: users.email }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + if (!target) return { status: "error", message: "ไม่พบบัญชีนี้" }; + try { await getAuth().api.removeUser({ body: { userId }, headers: await headers(), }); + await writeAuditLogBestEffort(auditActor(session.user), { + action: "admin_account.removed", + targetType: "admin_account", + targetId: userId, + metadata: { + accountLabel: target.name.trim() || target.email, + }, + }); } catch { return { status: "error", message: "ลบบัญชีไม่สำเร็จ โปรดลองอีกครั้ง" }; } revalidatePath("/admin/register"); - securityLog("account-removed", { actorId: session.user.id, targetId: userId }); return { status: "success" }; } diff --git a/app/api/admin/media/[id]/complete/route.ts b/app/api/admin/media/[id]/complete/route.ts index 740735e..67d7f84 100644 --- a/app/api/admin/media/[id]/complete/route.ts +++ b/app/api/admin/media/[id]/complete/route.ts @@ -4,6 +4,7 @@ import * as z from "zod"; import { requireAdmin } from "@/lib/auth/server"; import { completePendingMedia } from "@/lib/media/repository"; +import { auditActor } from "@/lib/audit-log"; export async function POST( request: Request, @@ -17,7 +18,9 @@ export async function POST( if (!z.string().uuid().safeParse(id).success) { return Response.json({ error: "invalid-id" }, { status: 400 }); } - const result = await withUploadSlot(() => completePendingMedia(id)); + const result = await withUploadSlot(() => + completePendingMedia(id, auditActor(admin.user)), + ); if (result.status === "ready") { return Response.json({ media: { diff --git a/app/api/admin/media/[id]/route.ts b/app/api/admin/media/[id]/route.ts index 0889da2..0c94c9c 100644 --- a/app/api/admin/media/[id]/route.ts +++ b/app/api/admin/media/[id]/route.ts @@ -4,6 +4,7 @@ import * as z from "zod"; import { requireAdmin } from "@/lib/auth/server"; import { discardUnreferencedMedia } from "@/lib/media/repository"; +import { auditActor } from "@/lib/audit-log"; export async function DELETE( request: Request, @@ -17,7 +18,7 @@ export async function DELETE( if (!z.string().uuid().safeParse(id).success) { return Response.json({ error: "invalid-id" }, { status: 400 }); } - const deleted = await discardUnreferencedMedia(id); + const deleted = await discardUnreferencedMedia(id, auditActor(admin.user)); return deleted ? new Response(null, { status: 204 }) : Response.json({ error: "media-is-referenced-or-missing" }, { status: 409 }); diff --git a/app/api/admin/media/route.ts b/app/api/admin/media/route.ts index 0de9c2d..149450a 100644 --- a/app/api/admin/media/route.ts +++ b/app/api/admin/media/route.ts @@ -1,8 +1,9 @@ import { requireAdmin } from "@/lib/auth/server"; import { uploadPublicMedia } from "@/lib/media/repository"; import { MAX_MEDIA_BYTES, mediaUploadSchema } from "@/lib/media/validation"; -import { boundedBody, errorResponse, HttpError, requireSameOrigin, securityLog, withUploadSlot } from "@/lib/security/http"; +import { boundedBody, errorResponse, HttpError, requireSameOrigin, withUploadSlot } from "@/lib/security/http"; import { limitRequest } from "@/lib/security/rate-limit"; +import { auditActor } from "@/lib/audit-log"; export async function POST(request: Request) { try { @@ -26,8 +27,7 @@ export async function POST(request: Request) { if (!mediaUploadSchema.safeParse({ fileName: file.name, mimeType: file.type, byteSize: file.size }).success) { throw new HttpError(415, "invalid-image"); } - const result = await uploadPublicMedia(file, admin.user.id); - securityLog("media-uploaded", { actorId: admin.user.id, targetId: result.id }); + const result = await uploadPublicMedia(file, auditActor(admin.user)); return Response.json(result, { status: 201, headers: { "Cache-Control": "no-store" } }); }); } catch (cause) { diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index 6da1140..88383fa 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -8,6 +8,7 @@ import { ImageIcon, LayoutDashboardIcon, BookOpenIcon, + ClipboardListIcon, LogOutIcon, PlusIcon, UserPlusIcon, @@ -31,6 +32,15 @@ export function AdminHeader() { ไกด์เกนชินไม่ใช่เกมมือถือ