feat(cms): improve catalog, editorial recovery and operations
This commit is contained in:
1 parent
2fead134e6
commit
c389c3893d
122 files changed
+8137
-703
No files matched your search
@@ -6,6 +6,7 @@ import { deleteArticle, updateArticle } from "@/actions/admin-articles";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { articleEditToken } from "@/lib/article-edit-token";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
@@ -62,6 +63,8 @@ export default async function EditArticle({
|
||||
</p>
|
||||
) : null}
|
||||
<ArticleForm
|
||||
articleKey={String(article.id)}
|
||||
baseToken={articleEditToken(article)}
|
||||
action={async (fd) => {
|
||||
"use server";
|
||||
fd.set("id", String(article.id));
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
export function ArticleListSubmit() {
|
||||
const { pending } = useFormStatus();
|
||||
const t = useTranslations("pages.admin.articles");
|
||||
return (
|
||||
<Button type="submit" disabled={pending} aria-busy={pending}>
|
||||
{pending ? t("listLoading") : t("listApply")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+141
-77
@@ -1,107 +1,171 @@
|
||||
import { desc, inArray } from "drizzle-orm";
|
||||
import { Newspaper } from "lucide-react";
|
||||
import Form from "next/form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
||||
import { ArticleCard } from "@/components/admin/article-card";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { loadArticleList } from "@/lib/services/article-list";
|
||||
import { ArticleListSubmit } from "./list-submit";
|
||||
|
||||
export default async function AdminArticles({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
searchParams: Promise<{
|
||||
error?: string;
|
||||
search?: string;
|
||||
status?: string;
|
||||
page?: string;
|
||||
}>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) {
|
||||
if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank))
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const { error } = await searchParams;
|
||||
const params = await searchParams;
|
||||
const t = await getTranslations("pages.admin.articles");
|
||||
const articles = await db
|
||||
.select({
|
||||
id: WebsiteArticles.id,
|
||||
title: WebsiteArticles.title,
|
||||
slug: WebsiteArticles.slug,
|
||||
image: WebsiteArticles.image,
|
||||
createdAt: WebsiteArticles.createdAt,
|
||||
userId: WebsiteArticles.userId,
|
||||
})
|
||||
.from(WebsiteArticles)
|
||||
.orderBy(desc(WebsiteArticles.createdAt))
|
||||
.limit(50)
|
||||
.catch(() => []);
|
||||
|
||||
const authorMap = new Map<number, string>();
|
||||
const authorIds = articles
|
||||
.map((a) => a.userId)
|
||||
.filter((id): id is number => Boolean(id));
|
||||
if (authorIds.length > 0) {
|
||||
const authors = await db
|
||||
.select({ id: User.id, username: User.username })
|
||||
.from(User)
|
||||
.where(inArray(User.id, authorIds))
|
||||
.catch(() => []);
|
||||
for (const u of authors) authorMap.set(u.id, u.username);
|
||||
let result: Awaited<ReturnType<typeof loadArticleList>> | null = null;
|
||||
try {
|
||||
result = await loadArticleList({
|
||||
status: params.status,
|
||||
search: params.search,
|
||||
page: Number(params.page ?? 1),
|
||||
});
|
||||
} catch {
|
||||
/* Render a retry state instead of an empty article list. */
|
||||
}
|
||||
|
||||
const latest = articles[0]?.createdAt
|
||||
? formatDate(articles[0].createdAt, "date")
|
||||
: "—";
|
||||
|
||||
const status =
|
||||
result?.status ??
|
||||
(["draft", "published", "scheduled"].includes(params.status ?? "")
|
||||
? params.status
|
||||
: "all");
|
||||
const search = result?.search ?? (params.search ?? "").slice(0, 191);
|
||||
const href = (page: number) =>
|
||||
`/admin/articles?${new URLSearchParams({ status: status ?? "all", search, page: String(page) })}`;
|
||||
return (
|
||||
<AdminPageShell
|
||||
icon={Newspaper}
|
||||
title={t("title")}
|
||||
subtitle={t("subtitle")}
|
||||
actions={
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/articles/new">{t("newArticle")}</Link>
|
||||
</Button>
|
||||
canAccess(permissions, PERMS.NEWS_EDIT, session.user.rank) ? (
|
||||
<Button asChild>
|
||||
<Link href="/admin/articles/new">{t("newArticle")}</Link>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<p className="text-xs text-[var(--admin-error)] mb-4">Error: {error}</p>
|
||||
{params.error ? (
|
||||
<p role="alert" className="text-sm text-[var(--admin-error)] mb-4">
|
||||
{t("listActionError")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard
|
||||
label={t("articlesShown")}
|
||||
value={articles.length}
|
||||
icon="📰"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("latest")}
|
||||
value={latest}
|
||||
hint={t("mostRecentPost")}
|
||||
icon="🕒"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-2">
|
||||
<h2 className="admin-section-title">{t("recentArticles")}</h2>
|
||||
{articles.length === 0 ? (
|
||||
<div className="admin-empty">{t("noArticles")}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
|
||||
{articles.map((a) => (
|
||||
<ArticleCard
|
||||
key={String(a.id)}
|
||||
id={String(a.id)}
|
||||
title={a.title}
|
||||
image={a.image}
|
||||
createdAt={a.createdAt ? a.createdAt.toISOString() : null}
|
||||
author={authorMap.get(a.userId ?? -1)}
|
||||
/>
|
||||
<Form
|
||||
action="/admin/articles"
|
||||
className="admin-card mb-5 flex flex-wrap items-end gap-3"
|
||||
key={`${status}:${search}`}
|
||||
>
|
||||
<label htmlFor="article-status" className="grid gap-1 text-sm">
|
||||
<span>{t("listStatus")}</span>
|
||||
<select
|
||||
id="article-status"
|
||||
name="status"
|
||||
defaultValue={status}
|
||||
className="h-9 rounded-md border bg-[var(--admin-surface)] px-3"
|
||||
>
|
||||
{["all", "draft", "published", "scheduled"].map((value) => (
|
||||
<option value={value} key={value}>
|
||||
{t(`listStatus_${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="article-search"
|
||||
className="grid flex-1 min-w-0 gap-1 text-sm"
|
||||
>
|
||||
<span>{t("listSearch")}</span>
|
||||
<Input
|
||||
id="article-search"
|
||||
name="search"
|
||||
defaultValue={search}
|
||||
maxLength={191}
|
||||
/>
|
||||
</label>
|
||||
<ArticleListSubmit />
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/articles">{t("listReset")}</Link>
|
||||
</Button>
|
||||
</Form>
|
||||
{!result ? (
|
||||
<div className="admin-card space-y-3" role="alert">
|
||||
<p>{t("listUnavailable")}</p>
|
||||
<Link
|
||||
className="btn btn-outline"
|
||||
href={href(Number(params.page) || 1)}
|
||||
>
|
||||
{t("listRetry")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
|
||||
{t("listTotal", { count: result.total })}
|
||||
</p>
|
||||
{result.rows.length === 0 ? (
|
||||
<div className="admin-empty">
|
||||
{search || status !== "all"
|
||||
? t("listNoMatches")
|
||||
: t("noArticles")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,260px),1fr))] gap-4">
|
||||
{result.rows.map((article) => (
|
||||
<div key={String(article.id)} className="min-w-0 space-y-2">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{["draft", "published", "scheduled"].includes(
|
||||
article.status,
|
||||
)
|
||||
? t(`listStatus_${article.status}`)
|
||||
: article.status}
|
||||
</span>
|
||||
<ArticleCard
|
||||
id={String(article.id)}
|
||||
title={article.title}
|
||||
image={article.image}
|
||||
createdAt={article.createdAt?.toISOString() ?? null}
|
||||
author={article.author ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<nav
|
||||
aria-label={t("listPagination")}
|
||||
className="mt-5 flex flex-wrap items-center justify-between gap-3"
|
||||
>
|
||||
{result.page > 1 ? (
|
||||
<Link className="btn btn-outline" href={href(result.page - 1)}>
|
||||
{t("listPrevious")}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{t("listPage", { page: result.page, total: result.lastPage })}
|
||||
</span>
|
||||
{result.page < result.lastPage ? (
|
||||
<Link className="btn btn-outline" href={href(result.page + 1)}>
|
||||
{t("listNext")}
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</AdminPageShell>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
OnlineUsersWidget,
|
||||
StatusCard,
|
||||
} from "@/components/admin/dashboard";
|
||||
import { OperationsInbox } from "@/components/admin/operations-inbox";
|
||||
import Link from "@/components/link";
|
||||
import { env } from "@/env";
|
||||
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
||||
@@ -126,6 +127,7 @@ export default async function CommandoCentrum() {
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<OperationsInbox />
|
||||
{/* ── Live status ────────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
guard: vi.fn(),
|
||||
resolve: vi.fn(),
|
||||
assign: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: mocks.guard }));
|
||||
vi.mock("@/lib/error-monitor", () => ({
|
||||
ErrorStore: class {
|
||||
resolve = mocks.resolve;
|
||||
assign = mocks.assign;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
@@ -17,7 +19,7 @@ vi.mock("@/lib/permissions", () => ({
|
||||
vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.audit }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { resolveCmsError } from "./actions";
|
||||
import { assignCmsError, resolveCmsError } from "./actions";
|
||||
|
||||
it("requires edit permission before resolving or auditing", async () => {
|
||||
mocks.guard.mockRejectedValue(new Error("denied"));
|
||||
@@ -37,3 +39,33 @@ it("records who resolved the problem", async () => {
|
||||
expect.objectContaining({ userId: 42, action: "cms.error.resolve" }),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
it("requires edit permission before assigning", async () => {
|
||||
mocks.guard.mockRejectedValue(new Error("denied"));
|
||||
await expect(assignCmsError(new FormData())).rejects.toThrow("denied");
|
||||
expect(mocks.assign).not.toHaveBeenCalled();
|
||||
});
|
||||
it("assigns only the authenticated staff member, ignoring submitted user IDs", async () => {
|
||||
mocks.guard.mockResolvedValue({ id: 42 });
|
||||
const data = new FormData();
|
||||
data.set("fingerprint", "aabbccddeeff0011");
|
||||
data.set("assignment", "self");
|
||||
data.set("userId", "999");
|
||||
await assignCmsError(data);
|
||||
expect(mocks.guard).toHaveBeenCalledWith("admin.devops.edit");
|
||||
expect(mocks.assign).toHaveBeenCalledWith("aabbccddeeff0011", 42);
|
||||
expect(mocks.audit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 42, action: "cms.error.assign" }),
|
||||
);
|
||||
data.set("assignment", "clear");
|
||||
await assignCmsError(data);
|
||||
expect(mocks.assign).toHaveBeenLastCalledWith("aabbccddeeff0011", null);
|
||||
});
|
||||
it("rejects unsupported assignment commands", async () => {
|
||||
mocks.guard.mockResolvedValue({ id: 42 });
|
||||
const data = new FormData();
|
||||
data.set("assignment", "other");
|
||||
await expect(assignCmsError(data)).rejects.toThrow("Invalid assignment");
|
||||
expect(mocks.assign).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -15,3 +15,20 @@ export async function resolveCmsError(data: FormData) {
|
||||
});
|
||||
revalidatePath("/admin/devops/cms-errors");
|
||||
}
|
||||
|
||||
export async function assignCmsError(data: FormData) {
|
||||
const staff = await requirePermission(PERMS.DEVOPS_EDIT);
|
||||
const mode = data.get("assignment");
|
||||
if (mode !== "self" && mode !== "clear")
|
||||
throw new Error("Invalid assignment operation");
|
||||
const fingerprint = String(data.get("fingerprint") ?? "");
|
||||
const userId = mode === "self" ? staff.id : null;
|
||||
await new ErrorStore().assign(fingerprint, userId);
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
action: "cms.error.assign",
|
||||
target: "cms-error",
|
||||
after: { fingerprint, assignedUserId: userId },
|
||||
});
|
||||
revalidatePath("/admin/devops/cms-errors");
|
||||
}
|
||||
@@ -1,21 +1,35 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import Link from "@/components/link";
|
||||
|
||||
import { summarizeErrorGroup } from "@/lib/error-groups";
|
||||
|
||||
import { ErrorStore } from "@/lib/error-monitor";
|
||||
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { resolveCmsError } from "./actions";
|
||||
|
||||
import { assignCmsError, resolveCmsError } from "./actions";
|
||||
|
||||
export default async function CmsErrorsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
|
||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
|
||||
redirect("/admin");
|
||||
|
||||
const t = await getTranslations("pages.admin.cmsErrors");
|
||||
|
||||
const params = await searchParams;
|
||||
|
||||
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
|
||||
|
||||
let result: Awaited<ReturnType<ErrorStore["read"]>>;
|
||||
|
||||
try {
|
||||
result = await new ErrorStore().read();
|
||||
} catch {
|
||||
@@ -25,28 +39,48 @@ export default async function CmsErrorsPage({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const filtered = result.records.filter(
|
||||
(r) =>
|
||||
(!params.source || r.source === params.source) &&
|
||||
(params.status !== "open" || !r.resolved) &&
|
||||
(params.status !== "resolved" || r.resolved) &&
|
||||
(!query || JSON.stringify(r).toLowerCase().includes(query)),
|
||||
);
|
||||
const groups = new Map<string, typeof filtered>();
|
||||
for (const r of filtered) {
|
||||
const list = groups.get(r.fingerprint) ?? [];
|
||||
list.push(r);
|
||||
groups.set(r.fingerprint, list);
|
||||
|
||||
const allGroups = new Map<string, typeof result.records>();
|
||||
|
||||
for (const record of result.records) {
|
||||
const events = allGroups.get(record.fingerprint) ?? [];
|
||||
|
||||
events.push(record);
|
||||
allGroups.set(record.fingerprint, events);
|
||||
}
|
||||
|
||||
const groups = new Map(
|
||||
[...allGroups.entries()].filter(([, events]) => {
|
||||
const latest = summarizeErrorGroup(events).latest;
|
||||
|
||||
return (
|
||||
latest &&
|
||||
(!params.source || latest.source === params.source) &&
|
||||
(params.status !== "open" || !latest.resolved) &&
|
||||
(params.status !== "resolved" || latest.resolved) &&
|
||||
(!query ||
|
||||
events.some((record) =>
|
||||
JSON.stringify(record).toLowerCase().includes(query),
|
||||
))
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const filtered = [...groups.values()].flat();
|
||||
|
||||
const page = Math.max(
|
||||
1,
|
||||
|
||||
Math.min(
|
||||
Number(params.page) || 1,
|
||||
|
||||
Math.max(1, Math.ceil(groups.size / 25)),
|
||||
),
|
||||
);
|
||||
|
||||
const pageHref = (next: number) =>
|
||||
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
@@ -104,9 +138,16 @@ export default async function CmsErrorsPage({
|
||||
<div className="admin-card">{t("empty")}</div>
|
||||
) : null}
|
||||
{[...groups.entries()]
|
||||
|
||||
.slice((page - 1) * 25, page * 25)
|
||||
|
||||
.map(([fingerprint, events]) => {
|
||||
const r = events[0];
|
||||
const metrics = summarizeErrorGroup(events);
|
||||
|
||||
const r = metrics.latest;
|
||||
|
||||
if (!r) return null;
|
||||
|
||||
return (
|
||||
<details key={fingerprint} className="admin-card overflow-hidden">
|
||||
<summary className="cursor-pointer">
|
||||
@@ -118,6 +159,105 @@ export default async function CmsErrorsPage({
|
||||
<p className="text-sm break-words">{r.message}</p>
|
||||
</summary>
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<dl className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("firstSeen")}</dt>
|
||||
<dd>
|
||||
<time dateTime={metrics.firstAt ?? ""}>
|
||||
{metrics.firstAt}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("lastSeen")}</dt>
|
||||
<dd>
|
||||
<time dateTime={metrics.lastAt ?? ""}>
|
||||
{metrics.lastAt}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("affectedUsers")}
|
||||
</dt>
|
||||
<dd>{metrics.affectedUsers}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("unidentifiedEvents")}
|
||||
</dt>
|
||||
<dd>{metrics.unidentified}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("metricsScope")}
|
||||
</p>
|
||||
<p>
|
||||
{r.assignment
|
||||
? t("assignedStaff", { id: r.assignment.userId })
|
||||
: t("unassigned")}
|
||||
</p>
|
||||
{canAccess(
|
||||
permissions,
|
||||
PERMS.DEVOPS_EDIT,
|
||||
session.user.rank,
|
||||
) && (
|
||||
<form
|
||||
action={assignCmsError}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="fingerprint"
|
||||
value={fingerprint}
|
||||
/>
|
||||
{r.assignment?.userId !== session.user.id && (
|
||||
<button
|
||||
type="submit"
|
||||
name="assignment"
|
||||
value="self"
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{t("assignSelf")}
|
||||
</button>
|
||||
)}
|
||||
{r.assignment && (
|
||||
<button
|
||||
type="submit"
|
||||
name="assignment"
|
||||
value="clear"
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{t("clearAssignment")}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{metrics.operationLink && (
|
||||
<Link
|
||||
href={metrics.operationLink}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{t("openOperation")}
|
||||
</Link>
|
||||
)}
|
||||
<details>
|
||||
<summary className="cursor-pointer">
|
||||
{t("releaseOccurrences")}
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{metrics.releases.map((release) => (
|
||||
<li key={release.release} className="break-all">
|
||||
<code>{release.release}</code> ·{" "}
|
||||
{t("occurrences", { count: release.count })} ·{" "}
|
||||
{release.firstAt} — {release.lastAt}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("releaseScope")}
|
||||
</p>
|
||||
</details>
|
||||
<p>
|
||||
{t("reference")}: <code className="break-all">{r.id}</code>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ inspect: vi.fn(), access: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
getAdminContext: async () => ({
|
||||
session: { user: { rank: 1 } },
|
||||
permissions: {},
|
||||
}),
|
||||
canAccess: mocks.access,
|
||||
PERMS: { DEVOPS_VIEW: "devops.view" },
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: () => {
|
||||
throw Error("redirect");
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/admin/installation", () => ({
|
||||
inspectInstallation: mocks.inspect,
|
||||
}));
|
||||
vi.mock("../../commandocentrum/refresh-status", () => ({
|
||||
RefreshStatus: () => null,
|
||||
}));
|
||||
|
||||
import Page from "./page";
|
||||
|
||||
it("rejects unauthorized access before checking database, filesystem or worker", async () => {
|
||||
mocks.access.mockReturnValue(false);
|
||||
await expect(Page()).rejects.toThrow("redirect");
|
||||
expect(mocks.inspect).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { inspectInstallation } from "@/lib/admin/installation";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { RefreshStatus } from "../../commandocentrum/refresh-status";
|
||||
export default async function InstallationPage() {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
|
||||
redirect("/admin");
|
||||
const t = await getTranslations("pages.admin.installation");
|
||||
const result = await inspectInstallation();
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<h1 className="text-2xl font-semibold">{t("title")}</h1>
|
||||
<p>{t("hint")}</p>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{t("checked", {
|
||||
time: formatDate(new Date(result.checkedAt), "datetime-seconds"),
|
||||
ms: result.durationMs,
|
||||
})}
|
||||
</p>
|
||||
<RefreshStatus label={t("recheck")} />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{result.rows.map((row) => (
|
||||
<section key={row.key} className="admin-card space-y-2 min-w-0">
|
||||
<h2 className="font-semibold">{t(row.key)}</h2>
|
||||
<p
|
||||
className={
|
||||
row.state === "failed"
|
||||
? "text-destructive"
|
||||
: row.state === "ok"
|
||||
? "text-[var(--admin-success)]"
|
||||
: "text-[var(--admin-text-muted)]"
|
||||
}
|
||||
>
|
||||
{t(row.state)}
|
||||
</p>
|
||||
{row.detail ? (
|
||||
<code className="block break-all text-xs">{row.detail}</code>
|
||||
) : null}
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
||||
{t(`${row.key}Hint`)}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,12 +53,16 @@ export default async function DevOpsPage() {
|
||||
|
||||
const t = await getTranslations("pages.admin.devops");
|
||||
const data = await getDevOpsData();
|
||||
const installation = await getTranslations("pages.admin.installation");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<a href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||
{t("cmsErrors")}
|
||||
</a>
|
||||
<Link href="/admin/devops/installation" className="btn btn-outline">
|
||||
{installation("title")}
|
||||
</Link>
|
||||
{/* Status cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FormEvent, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const keys = ["actor", "action", "from", "to"] as const;
|
||||
export function AuditFilters() {
|
||||
const t = useTranslations("pages.admin.logs.audit");
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
function apply(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const values = new FormData(event.currentTarget);
|
||||
const next = new URLSearchParams(params.toString());
|
||||
for (const key of keys) {
|
||||
const value = String(values.get(key) ?? "").trim();
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
}
|
||||
next.set("page", "1");
|
||||
startTransition(() => router.replace(`?${next}`, { scroll: false }));
|
||||
}
|
||||
function clear() {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
for (const key of keys) next.delete(key);
|
||||
next.set("page", "1");
|
||||
startTransition(() => router.replace(`?${next}`, { scroll: false }));
|
||||
}
|
||||
return (
|
||||
<form onSubmit={apply} key={params.toString()} aria-busy={pending}>
|
||||
<fieldset disabled={pending} className="space-y-3 rounded-xl border p-4">
|
||||
<legend className="px-1 text-sm font-medium">
|
||||
{t("filtersTitle")}
|
||||
</legend>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{keys.map((key) => (
|
||||
<label
|
||||
htmlFor={`audit-${key}`}
|
||||
key={key}
|
||||
className="space-y-1 text-sm"
|
||||
>
|
||||
<span>{t(`filter${key[0].toUpperCase()}${key.slice(1)}`)}</span>
|
||||
<Input
|
||||
id={`audit-${key}`}
|
||||
name={key}
|
||||
type={key === "from" || key === "to" ? "date" : "text"}
|
||||
maxLength={191}
|
||||
defaultValue={params.get(key) ?? ""}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("filtersHint")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" size="sm">
|
||||
{t("applyFilters")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={clear}>
|
||||
{t("resetFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
import { AuditFilters } from "./audit-filters";
|
||||
|
||||
interface AuditRow {
|
||||
id: number;
|
||||
@@ -14,75 +15,73 @@ interface AuditRow {
|
||||
target: string;
|
||||
targetId: number | null;
|
||||
diff: string | null;
|
||||
before: string | null;
|
||||
after: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function DiffCell({ diff }: { diff: string | null }) {
|
||||
function DiffCell({ row }: { row: AuditRow }) {
|
||||
const t = useTranslations("pages.admin.logs");
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!diff)
|
||||
const { changes, invalid } = readAuditChanges(
|
||||
row.diff,
|
||||
row.before,
|
||||
row.after,
|
||||
);
|
||||
if (invalid)
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("audit.invalidDetails")}
|
||||
</span>
|
||||
);
|
||||
if (!changes.length)
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
|
||||
let parsed: Record<string, { from: unknown; to: unknown }>;
|
||||
try {
|
||||
parsed = JSON.parse(diff);
|
||||
} catch {
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
}
|
||||
|
||||
const keys = Object.keys(parsed);
|
||||
if (keys.length === 0)
|
||||
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
|
||||
|
||||
const summary =
|
||||
keys.length === 1
|
||||
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
|
||||
: t("audit.changesCount", { count: keys.length });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs font-normal"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{summary}
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="mt-1 space-y-0.5 text-xs">
|
||||
{keys.map((k) => (
|
||||
<div key={k} className="flex gap-1">
|
||||
<span className="font-medium">{k}:</span>
|
||||
<span className="text-[var(--admin-error)] line-through">
|
||||
{String(parsed[k].from ?? "∅")}
|
||||
</span>
|
||||
<span>→</span>
|
||||
<span className="text-[var(--admin-success)]">
|
||||
{String(parsed[k].to ?? "∅")}
|
||||
</span>
|
||||
<details className="max-w-xl text-xs">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
{t("audit.changesCount", { count: changes.length })}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-3">
|
||||
{changes.map((change) => (
|
||||
<div key={change.key} className="space-y-1">
|
||||
<p className="font-medium break-all">{change.key}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("audit.beforeValue")}
|
||||
</span>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border p-2">
|
||||
{formatAuditValue(change.from)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("audit.afterValue")}
|
||||
</span>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border p-2">
|
||||
{formatAuditValue(change.to)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuditTableProps {
|
||||
data: PaginatedResult<AuditRow>;
|
||||
}
|
||||
|
||||
export function AuditTable({ data }: AuditTableProps) {
|
||||
const params = useSearchParams();
|
||||
const t = useTranslations("pages.admin.logs");
|
||||
|
||||
const columns: DataTableColumn<AuditRow>[] = [
|
||||
{ key: "id", label: t("colId"), sortable: true },
|
||||
{ key: "username", label: t("colUser"), sortable: true },
|
||||
{ key: "id", label: t("colId") },
|
||||
{ key: "username", label: t("colUser") },
|
||||
{
|
||||
key: "action",
|
||||
label: t("colAction"),
|
||||
sortable: true,
|
||||
render: (value) => {
|
||||
const v = String(value);
|
||||
let variant: "default" | "secondary" | "destructive" = "secondary";
|
||||
@@ -92,7 +91,7 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
return <Badge variant={variant}>{v}</Badge>;
|
||||
},
|
||||
},
|
||||
{ key: "target", label: t("colTarget"), sortable: true },
|
||||
{ key: "target", label: t("colTarget") },
|
||||
{
|
||||
key: "targetId",
|
||||
label: t("audit.colTargetId"),
|
||||
@@ -103,12 +102,11 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
{
|
||||
key: "diff",
|
||||
label: t("audit.colDetails"),
|
||||
render: (_value, row) => <DiffCell diff={row.diff} />,
|
||||
render: (_value, row) => <DiffCell row={row} />,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: t("audit.colDate"),
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<span>{String(value) || t("audit.notAvailable")}</span>
|
||||
),
|
||||
@@ -116,11 +114,15 @@ export function AuditTable({ data }: AuditTableProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("audit.searchPlaceholder")}
|
||||
exportUrl="/api/admin/export?type=audit"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<AuditFilters />
|
||||
<p className="text-xs text-muted-foreground">{t("audit.exportHint")}</p>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("audit.searchPlaceholder")}
|
||||
exportUrl={`/api/admin/logs/audit/export?${params.toString()}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,15 @@ export default async function AuditLogsPage({
|
||||
const sp = new URLSearchParams(params);
|
||||
const { search, perPage, page } = parseListParams(sp);
|
||||
|
||||
const result = await getAuditLogs({ search, page, perPage });
|
||||
const result = await getAuditLogs({
|
||||
search,
|
||||
page,
|
||||
perPage,
|
||||
actor: sp.get("actor") ?? undefined,
|
||||
action: sp.get("action") ?? undefined,
|
||||
from: sp.get("from") ?? undefined,
|
||||
to: sp.get("to") ?? undefined,
|
||||
});
|
||||
|
||||
const pagination = calcPagination(result.total, result.page, result.perPage);
|
||||
|
||||
@@ -32,6 +40,8 @@ export default async function AuditLogsPage({
|
||||
target: r.target,
|
||||
targetId: r.targetId,
|
||||
diff: r.diff,
|
||||
before: r.before,
|
||||
after: r.after,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
|
||||
|
||||
Reference in new issue
Block a user