Files
EpicNext-Cms/src/app/admin/logs/page.tsx
T
SimoandCursor 0e89d03940
Local Build and Deploy / deploy (push) Successful in 56s
Finish fine-grained ACL across remaining admin pages and actions.
Replace leftover requireStaff gates with module PERMS, drop hardcoded room rank thresholds, and expand contract tests so admin mutations cannot regress to dashboard-only checks.

Co-authored-by: Cursor <[email protected]>
2026-07-15 20:15:22 +02:00

237 lines
6.5 KiB
TypeScript

import { ScrollText } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { buildStaffActivityWhere } from "@/lib/admin/log-filters";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
const PER_PAGE = 25;
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
export default async function AdminStaffActivities({
searchParams,
}: {
searchParams: Promise<{
q?: string;
page?: string;
staffId?: string;
action?: string;
authorization?: string;
}>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.LOGS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.logs");
const sp = await searchParams;
const q = (sp.q ?? "").trim();
const page = Math.max(1, Number(sp.page ?? "1") || 1);
const staffId = sp.staffId ? Number(sp.staffId) : null;
const action = sp.action ?? null;
const authorizationOnly = sp.authorization === "1";
const whereClause = buildStaffActivityWhere({
q,
staffId,
action,
authorizationOnly,
});
const activities = await prisma.staffActivities
.findMany({
where: whereClause,
select: {
id: true,
userId: true,
action: true,
description: true,
targetType: true,
targetId: true,
ipAddress: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
skip: (page - 1) * PER_PAGE,
take: PER_PAGE,
})
.catch(() => []);
const staffIds = Array.from(
new Set(activities.map((a) => Number(a.userId))),
).filter((n) => n > 0);
let nameById = new Map<number, string>();
if (staffIds.length) {
const users = await prisma.user
.findMany({
where: { id: { in: staffIds } },
select: { id: true, username: true },
})
.catch(() => []);
nameById = new Map(users.map((x) => [x.id, x.username]));
}
const total = await prisma.staffActivities
.count({ where: whereClause })
.catch(() => 0);
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
const onlineCount = activities.filter(
(a) => a.createdAt && new Date(a.createdAt).getTime() > Date.now() - 60000,
).length;
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<ScrollText size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("totalActivities")} value={total} icon="📋" />
<StatusCard
label={t("onThisPage")}
value={activities.length}
icon="📄"
/>
<StatusCard
label={t("recent")}
value={onlineCount}
state={onlineCount > 0 ? "ok" : "neutral"}
icon="🟢"
/>
</div>
<form className="admin-card flex gap-2 mb-6 flex-wrap">
<input
name="q"
defaultValue={q}
placeholder={t("searchPlaceholder")}
className="flex-1 min-w-[200px]"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
name="authorization"
value="1"
defaultChecked={authorizationOnly}
/>
Authorization errors
</label>
<input
name="staffId"
defaultValue={staffId?.toString()}
placeholder={t("staffId")}
className="w-[120px]"
/>
<input
name="action"
defaultValue={action ?? ""}
placeholder={t("actionType")}
className="w-[160px]"
/>
<button type="submit" className="btn btn-primary">
{t("filter")}
</button>
{(q || staffId || action || authorizationOnly) && (
<a href="/admin/logs" className="btn btn-outline">
{t("clearFilters")}
</a>
)}
</form>
<section className="mt-6">
<h2 className="admin-section-title">
{t("activities", { count: total })}
</h2>
{activities.length === 0 ? (
<div className="admin-empty">{t("noResults")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colStaff")}</th>
<th>{t("colAction")}</th>
<th>{t("colDescription")}</th>
<th>{t("colTarget")}</th>
<th>{t("colIp")}</th>
</tr>
</thead>
<tbody>
{activities.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fromDate(a.createdAt)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{t("staffWithId", {
id: Number(a.userId),
name: nameById.get(Number(a.userId)) ?? "unknown",
})}
</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{a.action}
</span>
</td>
<td>{a.description}</td>
<td>
{a.targetType && a.targetId ? (
<span className="text-sm theme-text-muted dark:theme-text-muted">
{a.targetType === "user" ? t("userPrefix") : ""}#
{String(a.targetId)}
</span>
) : (
<span className="text-sm theme-text-muted dark:theme-text-muted">
—
</span>
)}
</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{a.ipAddress || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t("pagination", { page, pages, total })}
{page < pages ? (
<>
{" · "}
<a
href={`/admin/logs?q=${encodeURIComponent(q)}&staffId=${staffId || ""}&action=${action || ""}&authorization=${authorizationOnly ? "1" : ""}&page=${page + 1}`}
>
{t("next")} →
</a>
</>
) : null}
</p>
</main>
);
}