feat(cms): improve catalog, editorial recovery and operations

This commit is contained in:
Simo committed 2026-09-09 19:36:15 +02:00
1 parent 2fead134e6
commit c389c3893d
122 files changed
+8137 -703

No files matched your search

+78
View File
@@ -247,3 +247,81 @@
background: var(--color-primary);
border-radius: 999px;
}
.shortcutHint {
display: block;
margin-top: 4px;
font-size: 0.75rem;
font-weight: 400;
color: var(--color-text-muted);
}
.shortcut {
min-height: 64px;
text-align: center;
overflow-wrap: anywhere;
}
.shortcut:focus-visible,
.friend:focus-visible,
.room:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
.event {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.eventDetails {
min-width: 0;
flex: 1 1 220px;
overflow-wrap: anywhere;
}
.eventDetails h2 {
margin: 8px 0;
font-size: 1.15rem;
}
.eventDetails p {
margin: 0;
font-size: 0.85rem;
}
.eventPhase {
display: inline-block;
border-radius: 999px;
padding: 4px 10px;
color: var(--color-text-readable);
background: color-mix(
in srgb,
var(--color-primary) 14%,
var(--color-surface)
);
font-size: 0.75rem;
font-weight: 700;
}
@media (max-width: 400px) {
.hero {
gap: 12px;
padding: 14px;
}
.avatar {
width: 64px;
height: 100px;
}
.identity {
min-width: 0;
}
.identity h1 {
font-size: 1.4rem;
}
.wallet {
gap: 8px;
}
.currency {
padding: 10px;
}
.event > a {
width: 100%;
text-align: center;
}
}
File diff suppressed because it is too large. Load diff
+3
View File
@@ -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));
+13
View File
@@ -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>
);
}
File diff suppressed because it is too large. Load diff
+2
View File
@@ -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");
}
File diff suppressed because it is too large. Load diff
@@ -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>
);
}
+4
View File
@@ -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>
);
}
File diff suppressed because it is too large. Load diff
+11 -1
View File
@@ -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,
}));
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
options: {} as Record<string, unknown>,
rows: [
{
id: 1,
userId: 2,
username: '=HYPERLINK("bad")',
action: "update",
target: "user",
targetId: 3,
createdAt: "2026-09-09",
diff: '{"password":{"from":"oldsecret","to":"newsecret"}}',
before: null,
after: null,
},
],
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (options: Record<string, unknown>, handler: unknown) => {
state.options = options;
return handler;
},
}));
vi.mock("@/lib/permissions", () => ({ PERMS: { LOGS_VIEW: "logs.view" } }));
vi.mock("@/lib/services/audit", () => ({
getAuditLogs: vi.fn(async () => ({ rows: state.rows })),
}));
import { getAuditLogs } from "@/lib/services/audit";
import { GET } from "./route";
describe("audit CSV", () => {
beforeEach(() => {
vi.mocked(getAuditLogs).mockClear();
});
it("requires the audit viewing permission and passes the same filters and page", async () => {
expect(state.options).toMatchObject({ permission: "logs.view" });
const response = await GET(
new Request(
"https://example.test/api/admin/logs/audit/export?actor=Alice&action=update&from=2026-09-09&to=2026-09-10&search=user&page=2&perPage=10",
) as never,
{} as never,
);
expect(getAuditLogs).toHaveBeenCalledWith({
actor: "Alice",
action: "update",
from: "2026-09-09",
to: "2026-09-10",
search: "user",
page: 2,
perPage: 10,
});
expect(response.headers.get("Cache-Control")).toBe("no-store");
});
it("escapes CSV formula cells and redacts historical secrets", async () => {
const response = await GET(
new Request("https://example.test/api/admin/logs/audit/export") as never,
{} as never,
);
const csv = await response.text();
expect(csv).toContain("'=HYPERLINK");
expect(csv).not.toContain("oldsecret");
expect(csv).not.toContain("newsecret");
expect(csv).toContain("[Redacted]");
});
});
@@ -0,0 +1,69 @@
import { parseListParams } from "@/lib/admin-helpers";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { getAuditLogs } from "@/lib/services/audit";
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
function cell(value: unknown) {
const raw = value == null ? "" : String(value);
const safe = /^[\s]*[=+@-]/.test(raw) ? `'${raw}` : raw;
return `"${safe.replace(/"/g, '""')}"`;
}
export const GET = withAdmin(
{ permission: PERMS.LOGS_VIEW },
async (request) => {
const params = new URL(request.url).searchParams;
const { search, page, perPage } = parseListParams(params);
const result = await getAuditLogs({
search,
page,
perPage,
actor: params.get("actor") ?? undefined,
action: params.get("action") ?? undefined,
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
});
const rows: unknown[][] = [
[
"id",
"user_id",
"username",
"action",
"target",
"target_id",
"created_at",
"changes",
],
];
for (const row of result.rows) {
const details = readAuditChanges(row.diff, row.before, row.after);
rows.push([
row.id,
row.userId,
row.username,
row.action,
row.target,
row.targetId,
row.createdAt,
details.invalid
? "[Unavailable legacy details]"
: details.changes
.map(
(change) =>
`${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
)
.join("\n"),
]);
}
return new Response(
`\uFEFF${rows.map((row) => row.map(cell).join(",")).join("\r\n")}`,
{
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="audit-page.csv"',
"Cache-Control": "no-store",
},
},
);
},
);
@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
guard: vi.fn(),
create: vi.fn(),
list: vi.fn(),
cancel: vi.fn(),
after: vi.fn(),
ping: vi.fn(),
source: vi.fn(),
@@ -25,15 +26,17 @@ vi.mock("@/lib/redis", () => ({ redis: { ping: mocks.ping } }));
vi.mock("@/lib/services/furni-job-worker", () => ({
drainFurnitureImports: vi.fn(),
}));
vi.mock("@/lib/services/furni-job-store", () => ({
vi.mock("@/lib/services/furni-job-store", async (original) => ({
...(await original<typeof import("@/lib/services/furni-job-store")>()),
ImportJobStore: class {
create = mocks.create;
list = mocks.list;
requestCancellation = mocks.cancel;
},
}));
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
import { GET, POST } from "./route";
import { GET, PATCH, POST } from "./route";
const item = {
id: 1,
@@ -106,11 +109,23 @@ it("rejects a missing configured source", async () => {
).status,
).toBe(404);
});
it("only returns the current operators history", async () => {
mocks.list.mockResolvedValue([
{ id: "a", userId: 7 },
{ id: "b", userId: 9 },
]);
it("requests bounded owner-scoped history from the store", async () => {
mocks.list.mockResolvedValue([{ id: "a", userId: 7 }]);
const response = await GET(request({}), ctx);
expect(mocks.list).toHaveBeenCalledWith({ userId: 7, limit: 30 });
expect((await response.json()).jobs).toEqual([{ id: "a", userId: 7 }]);
});
it("requests cancellation with the authenticated owner", async () => {
const id = randomUUID();
mocks.cancel.mockResolvedValue({ id, cancelRequested: true });
const response = await PATCH(request({ id }), ctx);
expect(response.status).toBe(200);
expect(mocks.cancel).toHaveBeenCalledWith(id, 7);
});
it("does not reveal other owners' jobs", async () => {
mocks.cancel.mockResolvedValue(null);
expect((await PATCH(request({ id: randomUUID() }), ctx)).status).toBe(404);
});
it("rejects unsafe cancellation IDs", async () => {
expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400);
});
+20 -5
View File
@@ -6,7 +6,7 @@ import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs";
import { redis } from "@/lib/redis";
import { getSource } from "@/lib/services/clone-sources";
import { ImportJobStore } from "@/lib/services/furni-job-store";
import { ImportJobStore, validJobId } from "@/lib/services/furni-job-store";
import { drainFurnitureImports } from "@/lib/services/furni-job-worker";
const schema = z.object({
@@ -38,10 +38,10 @@ export const GET = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (_request, ctx) => {
after(drainFurnitureImports);
const jobs = (await new ImportJobStore().list())
.filter((job) => job.userId === ctx.session.user.id)
.reverse()
.slice(0, 30);
const jobs = await new ImportJobStore().list({
userId: ctx.session.user.id,
limit: 30,
});
return apiOk({ jobs });
},
);
@@ -82,3 +82,18 @@ export const POST = withAdmin(
return apiOk({ job });
},
);
export const PATCH = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
const body = await request.json().catch(() => null);
if (!validJobId(body?.id)) return apiError("Invalid import ID", 400);
const job = await new ImportJobStore().requestCancellation(
body.id,
ctx.session.user.id,
);
if (!job) return apiError("Import job not found", 404);
after(drainFurnitureImports);
return apiOk({ job });
},
);