diff --git a/apps/web/content/docs/content-engine/approvals.mdx b/apps/web/content/docs/content-engine/approvals.mdx
index bd0b14f02..e71ac2e1f 100644
--- a/apps/web/content/docs/content-engine/approvals.mdx
+++ b/apps/web/content/docs/content-engine/approvals.mdx
@@ -66,7 +66,7 @@ For a proposal, `values` is still the live record and `approval.values` holds wh
To list everything an author sent, call the core moderation module. It returns pending, approved and rejected submissions, newest first, for the signed-in member. Pass `pluginId` to keep only your plugin's submissions - the filter runs before the page is cut, so every page is full and `total` counts only yours:
-```ts title="plugins/forum/src/pages/my-submissions-data.ts"
+```ts
import { fetcher } from "@vitnode/core/tanstack/fetcher";
export const fetchMySubmissions = async (page: number) => {
@@ -83,7 +83,7 @@ export const fetchMySubmissions = async (page: number) => {
};
```
-Each item has `kind`, `status`, `title`, `contentTypeId`, `itemId`, the container, `reason` and the dates. A guest gets `401`.
+Each item has `kind`, `status`, `title`, `contentTypeId`, `itemId`, the container, `reason` and the dates. A guest gets `401`. The Forum plugin's `pending-page.tsx` renders this list at `/forum/me/pending`.
## What a decision does
diff --git a/apps/web/content/docs/content-engine/public-write-api.mdx b/apps/web/content/docs/content-engine/public-write-api.mdx
index 15ef0b777..c1bd01abf 100644
--- a/apps/web/content/docs/content-engine/public-write-api.mdx
+++ b/apps/web/content/docs/content-engine/public-write-api.mdx
@@ -263,7 +263,7 @@ The same member editing somebody else's draft gets `404` with `"code": "CONTENT_
### Rate limit
-`POST` and `PATCH` requests to a path containing `/content/`, outside `/admin/`, share a budget of 20 requests per 60 seconds per IP, on top of the write budget every mutation spends. Change it with `rateLimiter.contentWrite` in `apps/api/src/vitnode.api.config.ts`. It is off when `NODE_ENV=development`. See [Rate limiter](/docs/dev/advanced/rate-limiter) for the `429` body and Redis-backed counters.
+`POST` and `PATCH` requests to a path containing `/content/`, outside `/admin/`, share a budget of 20 requests per 60 seconds, on top of the write budget every mutation spends. Change it with `rateLimiter.contentWrite` in `apps/api/src/vitnode.api.config.ts`. The budget counts per signed-in user when the request carries a session cookie, and per IP otherwise (`rateLimiter.perUser`, on by default). It is off when `NODE_ENV=development`. See [Rate limiter](/docs/dev/advanced/rate-limiter) for the `429` body and Redis-backed counters.
### Codes
diff --git a/apps/web/content/docs/content-engine/public-write-frontend.mdx b/apps/web/content/docs/content-engine/public-write-frontend.mdx
index dd5a12789..8b5d8821f 100644
--- a/apps/web/content/docs/content-engine/public-write-frontend.mdx
+++ b/apps/web/content/docs/content-engine/public-write-frontend.mdx
@@ -641,6 +641,15 @@ Restart `pnpm dev` and sign in with a member account:
{/* Image prompt: The /discussions/new page of the example plugin: "Start a discussion" heading, a Title input and a Message rich text editor, and a "Post discussion" button. Light theme, 1440x900. */}
+## Approvals and edit windows
+
+`example.discussion` publishes straight away. When a content type is filed in a container with [publishing policies](/docs/content-engine/container-policies), the same routes can answer differently:
+
+- A create or edit that needs a moderator still answers `201` or `200`, with `approval.status: "pending"`. Tell the author it is waiting instead of saying it is live. `GET /{id}/edit` returns the pending proposal in `approval.values`. See [Approvals](/docs/content-engine/approvals#what-the-author-sees).
+- A create in a container with `create: "moderators"` answers `403` with the `moderators_only` issue. An owner edit after `authorEdit` stopped allowing it answers `403` with `edit_window_closed`.
+
+The Forum plugin handles all of these in `topic-edit-page.tsx` and `comment-composer.tsx`.
+
## Ask for a captcha
With `create.captcha: true`, every create needs a captcha token, as long as a [captcha provider](/docs/dev/captcha) is configured. Pass the deployment's captcha config to `AutoForm` and forward the token it hands to `onSubmit`:
@@ -680,7 +689,7 @@ The routes do not care how the form is built. Any form library, or none, works a
2. On `400`, `401`, `403` or `404`, read the body with `parseContentPublicWriteRejection` from `@vitnode/core/content`. It takes the parsed JSON and returns the rejection, or `null` for any other shape.
3. On `409`, check `code` for `CONTENT_VERSION_CONFLICT` or `CONTENT_UNIQUE_CONFLICT`.
-This plain React form turns a textarea into paragraphs and puts each issue under its field:
+For example, this plain React form, which you write yourself, turns a textarea into paragraphs and puts each issue under its field:
```tsx title="plugins/example/src/pages/quick-discussion-form.tsx"
import {
diff --git a/apps/web/content/docs/content-engine/reference.mdx b/apps/web/content/docs/content-engine/reference.mdx
index e6286dd52..097537852 100644
--- a/apps/web/content/docs/content-engine/reference.mdx
+++ b/apps/web/content/docs/content-engine/reference.mdx
@@ -303,7 +303,7 @@ visibility: true,
## `reports`
-`reports: true` lets signed-in readers report a published record. It registers a report target, adds `can_view_reports` and `can_manage_reports` to the moderator catalog, plus `can_view_hidden` and `can_hide` with `visibility`, and needs no migration. Pass `{ titleField, snapshotFields }` to choose what the report's snapshot keeps. Guide: [Enable reports](/docs/dev/moderation/enable-reports). Options: [Moderation reference](/docs/dev/moderation/reference#reports-on-a-content-type).
+`reports: true` lets signed-in readers report a published record. It registers a report target, adds `can_view_reports` and `can_manage_reports` to the moderator catalog, plus `can_view_hidden` and `can_hide` with `visibility`, and needs no migration. Pass `{ titleField, snapshotFields }` to choose what the report's snapshot keeps. Render the public button with `ReportButton` and `contentTarget(manifest.id, definition, record.id)`. Guide: [Enable reports](/docs/dev/moderation/enable-reports). Options and components: [Moderation reference](/docs/dev/moderation/reference#reports-on-a-content-type).
```ts title="plugins/blog/src/content/post.ts"
publicApi: { path: "blog", fields: ["id", "title", "friendlyUrl", "content"] },
diff --git a/apps/web/content/docs/content-engine/threads.mdx b/apps/web/content/docs/content-engine/threads.mdx
index ebc7052b7..7b3a47514 100644
--- a/apps/web/content/docs/content-engine/threads.mdx
+++ b/apps/web/content/docs/content-engine/threads.mdx
@@ -134,21 +134,28 @@ import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { manifest } from "@/manifest.gen";
-export const fetchTopicThread = async (topicId: number, after?: string) => {
+export const fetchThreadPage = async ({
+ after,
+ topicId,
+}: {
+ after?: string;
+ topicId: number;
+}) => {
const res = await fetcher({
plugin: manifest.id,
method: "get",
module: "content/comments",
path: "/thread",
- args: { query: { root: String(topicId), first: 20, after } },
+ args: { query: { root: String(topicId), first: 20, ...(after ? { after } : {}) } },
});
- if (!res.ok) throw new Error("Could not load the comments.");
+ if (res.status === 404) return null;
+ if (res.status !== 200) throw new Error("Loading the comments failed.");
return await res.json();
};
```
-`items` is already flattened depth first, so render it as a list and indent each item by its `depth`. Pass `pageInfo.endCursor` as `after` to load the next page.
+`items` is already flattened depth first, so render it as a list and indent each item by its `depth`. Pass `pageInfo.endCursor` as `after` to load the next page. A `404` means the root record is missing or not public. The Forum plugin's `thread-data.ts` also validates the response with Zod, and `thread.tsx` renders it as a nested list.
@@ -174,7 +181,7 @@ export const fetchTopicThread = async (topicId: number, after?: string) => {
"id": 17,
"slug": "k3v9q2m7xa",
"body": { "type": "doc", "content": [] },
- "topic": 42,
+ "topic": { "id": 42 },
"parent": null,
"publishedAt": "2026-10-13T09:20:00.000Z",
"createdAt": "2026-10-13T09:20:00.000Z",
@@ -189,7 +196,7 @@ export const fetchTopicThread = async (topicId: number, after?: string) => {
}
},
{ "depth": 2, "id": 18, "parentId": 17, "removed": true, "record": null },
- { "depth": 3, "id": 21, "parentId": 18, "removed": false, "record": { "id": 21, "parent": 18 } }
+ { "depth": 3, "id": 21, "parentId": 18, "removed": false, "record": { "id": 21, "parent": { "id": 18 } } }
],
"pageInfo": { "endCursor": "WyIyMDI2LTEwLTEzVDA5OjIwOjAwLjAwMFoiLDE3XQ", "hasNextPage": false, "totalCount": 1 },
"replyCount": 2,
@@ -202,7 +209,9 @@ export const fetchTopicThread = async (topicId: number, after?: string) => {
- **Limits.** One page carries at most 500 nodes and stops at `maxDepth`. `truncated: true` means some nested replies did not fit.
- **The root must be public.** A missing, draft or hidden topic answers `404`.
-`record` has the same shape as a record from the public list, including `people` summaries. A person is `null` when the account was deleted.
+`record` has the same shape as a record from the public list, including `people` summaries. A to-one relation such as `topic` or `parent` comes back as `{ id }`, not a bare id. A person is `null` when the account was deleted.
+
+Readers can report a reply like any other record: turn on [`reports`](/docs/dev/moderation/enable-reports#report-items-in-a-list) for the reply type and render a `ReportButton` per reply.
## Placement rules
diff --git a/apps/web/content/docs/dev/moderation/enable-reports.mdx b/apps/web/content/docs/dev/moderation/enable-reports.mdx
index b7a9d83cc..73d5639c6 100644
--- a/apps/web/content/docs/dev/moderation/enable-reports.mdx
+++ b/apps/web/content/docs/dev/moderation/enable-reports.mdx
@@ -75,29 +75,73 @@ Enabling reports grants nothing. An administrator still has to give these permis
### Place the Report button
-A plugin's own pages place the button themselves. Pass the record's stable id and the language the reader was served:
+A plugin's own pages place the button themselves. Pass the record as a `target` built with `contentTarget`, plus the language the reader was served:
```tsx title="plugins/blog/src/pages/post-page.tsx"
+import { contentTarget } from "@vitnode/core/content";
import { ReportButton } from "@vitnode/core/tanstack/moderation";
+import { blogPostContentType } from "@/content/post";
+import { manifest } from "@/manifest.gen";
+
{!metadata.hidden && metadata.itemId !== null ? (
) : null}
```
+`contentTarget(pluginId, definition, id)` returns `{ pluginId, type, id }`: your plugin id, the content type id (`"blog.post"`) and the record id. Building it from the definition keeps the type id in sync with what the API registered. `typeLabel` is the item's name, already translated, for the **Report article** title, so add it to your plugin's locale file:
+
+```json title="plugins/blog/src/locales/en.json"
+{
+ "@vitnode/blog": {
+ "post": {
+ "report_type": "article"
+ }
+ }
+}
+```
+
The blog reads `itemId`, `locale` and `requestedLocale` from the [delivery resolution](/docs/content-engine/public-pages), so the button reports the article in the language the reader actually saw, even after a fallback. The form loads only when the dialog opens. A signed-out reader gets a **Sign in** link instead of the form.
+## Report items in a list
+
+Comments, replies and other list items work the same way: the record id comes from the item you are rendering. The forum puts a button in each comment's action row, for everyone except the comment's author:
+
+```tsx title="plugins/forum/src/pages/thread.tsx"
+import { contentTarget } from "@vitnode/core/content";
+import { ReportButton } from "@vitnode/core/tanstack/moderation";
+
+import { commentContentType } from "@/content/comment";
+import { manifest } from "@/manifest.gen";
+
+const authorId = `${commentAnchorId(comment.slug)}-author`;
+
+
+
+
+
+{isOwner ? null : (
+
+)}
+```
+
+`comment.id` comes from the [thread](/docs/content-engine/threads) response. Comments are not localized, so the button needs no `locale`. `describedBy` points at the author's name, so a screen reader announces "Report comment, Jane" instead of 30 identical "Report comment" buttons.
+
+Hiding the button from the author is a choice the forum makes, not a server rule. Every button shares one request for its messages and loads the form only when its dialog opens. To open the dialog from your own trigger, such as a menu item, use [`ReportDialog`](/docs/dev/moderation/reference#reportdialog).
+
## Check the result
Rebuild with `pnpm build:plugins`, restart `pnpm dev`, sign in as a regular user and open a published article. Click **Report**, pick a reason and send it:
@@ -111,7 +155,7 @@ A toast says **Report sent**. Try sending it again and the toast tells you, poli
## What the server checks
-- The reporter must be signed in. A guest gets `401`.
+- The reporter must be signed in. A guest gets `401`, and a reader whose session ended mid-dialog sees a "sign in again" message.
- The target must be readable by the reporter through the public API. Anything else is `404`, the same answer as a typo.
- **Other** needs at least 10 characters of details. Details are at most 2000 characters.
- One reporter can hold one open report per target. Other reporters get their own reports.
diff --git a/apps/web/content/docs/dev/moderation/reference.mdx b/apps/web/content/docs/dev/moderation/reference.mdx
index 5d47ccd2b..eee531f5b 100644
--- a/apps/web/content/docs/dev/moderation/reference.mdx
+++ b/apps/web/content/docs/dev/moderation/reference.mdx
@@ -17,7 +17,7 @@ Exact names, types and behavior of the moderation APIs. For step-by-step guides,
`reports: true` uses both defaults. It throws without `publicApi`, for an unknown option, for `reports.enabled`, and for a field that does not exist or cannot be snapshotted.
-The server-side model can scope reports with `createContentModel(definition, { moderationScope: { column, family } })`, where `column` is a base-table column holding the resource id.
+The server-side model can scope reports with `createContentModel(definition, { moderationScope: { column, family } })`, where `column` is a base-table column holding the resource id. A content type that reaches its container through a relation, such as a forum comment through its topic, has no such column, so its report permissions stay unscoped.
## Generated moderator permissions
@@ -83,6 +83,52 @@ Content Engine types register their target automatically. Anything else register
`run` executes inside the report's transaction and returns `{ changed, effects }`. `effects` runs after the commit. When `run` throws, the transaction rolls back and no timeline entry is written.
+## Report UI
+
+Public pages report a target with the components from `@vitnode/core/tanstack/moderation`. Both load their own messages (`REPORT_NAMESPACES`) and lazy-load the form, and both show a **Sign in** link instead of the form to guests. For a walkthrough, see [Enable reports](/docs/dev/moderation/enable-reports).
+
+### `contentTarget`
+
+`contentTarget(pluginId, definition, id)` from `@vitnode/core/content` returns the `ContentTarget` of one record: `{ pluginId, type, id }`, where `type` is `definition.id` and `id` is the record id as a string. A custom report target uses the same shape with its own `type`.
+
+### `ReportButton`
+
+A ghost button labelled **Report** that opens the dialog. Its accessible name is "Report `typeLabel`".
+
+| Name | Type | Required | Default | Description |
+| ----------------- | ---------------- | -------- | ------- | --------------------------------------------------------------------------- |
+| `target` | `ContentTarget` | Yes | | The reported record |
+| `typeLabel` | `string` | Yes | | What the item is called, already translated, for "Report `typeLabel`" |
+| `describedBy` | `string` | No | | Id of the element naming the item, read after the label so list buttons differ |
+| `locale` | `string \| null` | No | `null` | The locale the reader was served, for moderators |
+| `requestedLocale` | `string \| null` | No | `null` | The locale the reader asked for, when the content fell back to another |
+| `className` | `string` | No | | Classes for the button |
+
+### `ReportDialog`
+
+The same dialog, controlled by you. Use it for your own trigger, such as a menu item, or to share one dialog across a long list. It takes `target`, `typeLabel`, `locale` and `requestedLocale` like `ReportButton`, plus `open: boolean` and `onOpenChange(open: boolean)`.
+
+```tsx
+import type { ContentTarget } from "@vitnode/core/content";
+
+import { ReportDialog } from "@vitnode/core/tanstack/moderation";
+
+const [reporting, setReporting] = useState(null);
+
+{reporting ? (
+ {
+ if (!open) setReporting(null);
+ }}
+ open
+ target={reporting}
+ typeLabel={t("report_type")}
+ />
+) : null}
+```
+
+The dialog shows a toast with a description after every outcome: sent, already reported (`409 duplicate`), no longer available (`404`), too many reports (`429`), session ended (`401`), invalid details (`400`) and anything else.
+
## Report lifecycle
| Status | Reached by |
diff --git a/packages/vitnode/src/api/modules/moderation/reports.test.ts b/packages/vitnode/src/api/modules/moderation/reports.test.ts
index fe564912f..b274413db 100644
--- a/packages/vitnode/src/api/modules/moderation/reports.test.ts
+++ b/packages/vitnode/src/api/modules/moderation/reports.test.ts
@@ -1,7 +1,7 @@
import type { Context } from "hono";
import { OpenAPIHono } from "@hono/zod-openapi";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import type { PermissionsStaffArgs } from "@/api/lib/permission-staff";
import type { ModerationCaptureResult } from "@/api/moderation";
@@ -14,6 +14,7 @@ import {
} from "@/database/moderation";
import { core_roles } from "@/database/roles";
import { core_users } from "@/database/users";
+import { MODERATION_LIMITS } from "@/lib/moderation/const";
import { createTestCache } from "@/tests/cache";
import { createMemoryDb } from "@/tests/memory-db";
import {
@@ -336,6 +337,62 @@ describe("filing a report", () => {
expect(h.memory.rows(core_moderation_report_events)).toEqual([]);
});
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("asks a guest to sign in", async () => {
+ const h = await harness();
+
+ const res = await h.report(0, "1");
+
+ expect(res.status).toBe(401);
+ expect(h.memory.rows(core_moderation_reports)).toEqual([]);
+ });
+
+ it.each([
+ [MODERATION_LIMITS.otherDescriptionMinLength - 1, 400],
+ [MODERATION_LIMITS.otherDescriptionMinLength, 201],
+ ])(
+ 'needs a description of the problem for "other" (%i characters: %i)',
+ async (length, status) => {
+ const h = await harness();
+
+ const res = await h.report(REPORTER, "1", {
+ description: "x".repeat(length),
+ reason: "other",
+ });
+
+ expect(res.status).toBe(status);
+ },
+ );
+
+ it("limits reports per hour and lets the reporter go on once it passes", async () => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.setSystemTime(new Date("2026-10-18T12:00:00.000Z"));
+ const h = await harness();
+ await h.memory.db.insert(core_moderation_reports).values(
+ Array.from({ length: MODERATION_LIMITS.reportsPerUserPerHour }, () => ({
+ openKey: null,
+ pluginId: FORUM,
+ reason: "spam" as const,
+ reporterId: REPORTER,
+ snapshot: {},
+ targetId: "2",
+ targetType: TYPE,
+ })),
+ );
+
+ const limited = await h.report(REPORTER, "1");
+ expect(limited.status).toBe(429);
+ expect(limited.headers.get("retry-after")).toBe("3600");
+ expect(await limited.json()).toEqual({ error: "rate_limited" });
+ expect((await h.report(SECOND_REPORTER, "1")).status).toBe(201);
+
+ vi.setSystemTime(new Date("2026-10-18T13:00:01.000Z"));
+ expect((await h.report(REPORTER, "1")).status).toBe(201);
+ });
+
it("answers 404 when the reporter cannot read the target", async () => {
const h = await harness();
diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts
index 1d37375a2..b3bc1335f 100644
--- a/packages/vitnode/src/content/index.ts
+++ b/packages/vitnode/src/content/index.ts
@@ -526,6 +526,8 @@ export {
} from "./sitemap";
export type { ContentSitemapEntry, ContentSitemapIndexEntry } from "./sitemap";
export { slugify } from "./slug";
+export { contentTarget } from "./target";
+export type { ContentTarget } from "./target";
export type {
AnyContentTypeDefinition,
ContentAdminActionConfig,
diff --git a/packages/vitnode/src/content/server/report-target.test.ts b/packages/vitnode/src/content/server/report-target.test.ts
new file mode 100644
index 000000000..fd9284c4f
--- /dev/null
+++ b/packages/vitnode/src/content/server/report-target.test.ts
@@ -0,0 +1,89 @@
+import type { Context } from "hono";
+
+import { describe, expect, it } from "vitest";
+
+import { core_users } from "@/database/users";
+import { createTestCache } from "@/tests/cache";
+import { createMemoryDb } from "@/tests/memory-db";
+
+import { defineContentType } from "../define";
+import { field } from "../fields";
+import { createContentModel } from "./model";
+import { contentReportTarget } from "./report-target";
+
+const PLUGIN = "@vitnode/test";
+
+const topics = defineContentType({
+ id: "test.reported-topic",
+ tableName: "test_reported_topics",
+ publication: true,
+ visibility: true,
+ fields: {
+ title: field.text({ required: true }),
+ slug: field.slug({ source: "title" }),
+ body: field.textarea({ required: true }),
+ sectionId: field.number({ integer: true, defaultValue: 0 }),
+ },
+ publicApi: {
+ path: "reported-topics",
+ fields: ["id", "title", "slug", "body"],
+ },
+ reports: { snapshotFields: ["body"], titleField: "title" },
+});
+const model = createContentModel(topics);
+
+const row = (id: number, overrides: Record) => ({
+ body: `Body ${id}`,
+ hiddenAt: null,
+ id,
+ publishedAt: new Date("2026-01-01T00:00:00.000Z"),
+ sectionId: 10,
+ slug: `topic-${id}`,
+ status: "published",
+ title: `Topic ${id}`,
+ ...overrides,
+});
+
+const capture = async (targetId: string) => {
+ const { db } = createMemoryDb([
+ [
+ model.table,
+ [
+ row(1, {}),
+ row(2, { hiddenAt: new Date() }),
+ row(3, { publishedAt: null, status: "draft" }),
+ ],
+ ],
+ [core_users, []],
+ ]);
+ const values: Record = {
+ cache: createTestCache(),
+ db,
+ user: { id: 5, roleId: 3 },
+ };
+ const c = { get: (key: string) => values[key] } as unknown as Context;
+
+ return await contentReportTarget(model, PLUGIN).capture(c, {
+ locale: null,
+ targetId,
+ });
+};
+
+describe("capturing a reported content record", () => {
+ it("snapshots the configured fields of a published record", async () => {
+ const snapshot = await capture("1");
+
+ expect(snapshot?.title).toBe("Topic 1");
+ expect(snapshot?.fields).toEqual([
+ expect.objectContaining({ kind: "text", name: "body", value: "Body 1" }),
+ ]);
+ });
+
+ it.each([
+ ["hidden", "2"],
+ ["draft", "3"],
+ ["missing", "4"],
+ ])("finds nothing to report on a %s record", async (_label, targetId) => {
+ expect(await capture(targetId)).toBeNull();
+ });
+});
diff --git a/packages/vitnode/src/content/target.ts b/packages/vitnode/src/content/target.ts
new file mode 100644
index 000000000..4ed1c2f23
--- /dev/null
+++ b/packages/vitnode/src/content/target.ts
@@ -0,0 +1,45 @@
+import type { ContentId } from "./ids";
+
+import { contentIdKey } from "./ids";
+
+/**
+ * One record a public action points at, such as the item a `ReportButton`
+ * reports. The same three values identify the record on the API, so build it
+ * with `contentTarget` instead of passing them around one by one.
+ *
+ * A custom report target (`defineReportTarget`) uses the same shape, with its
+ * own `type`.
+ */
+export interface ContentTarget {
+ /** The record's id, as the API reads it from a path. */
+ id: string;
+ /** The plugin that registered the content type - its `manifest.id`. */
+ pluginId: string;
+ /** The content type id, e.g. `"forum.comment"`. */
+ type: string;
+}
+
+/**
+ * Builds the `ContentTarget` of one record from the content type definition,
+ * so the type id never drifts from the one the API registered. Browser-safe:
+ * a content definition holds no server code.
+ *
+ * @example
+ * ```tsx
+ * import { contentTarget } from "@vitnode/core/content";
+ *
+ * import { commentContentType } from "@/content/comment";
+ * import { manifest } from "@/manifest.gen";
+ *
+ * const target = contentTarget(manifest.id, commentContentType, comment.id);
+ * ```
+ */
+export const contentTarget = (
+ pluginId: string,
+ definition: { id: string },
+ id: ContentId,
+): ContentTarget => ({
+ id: contentIdKey(id),
+ pluginId,
+ type: definition.id,
+});
diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json
index 8229b5e63..6842018ba 100644
--- a/packages/vitnode/src/locales/en.json
+++ b/packages/vitnode/src/locales/en.json
@@ -2207,6 +2207,7 @@
"duplicate": "You already reported this, and that report is still open.",
"not_found": "This content is no longer available to report.",
"rate_limited": "You have sent a lot of reports recently. Try again later.",
+ "unauthenticated": "Your session has ended. Sign in again to send the report.",
"validation": "Check the reason and the details, then try again.",
"unknown": "Something went wrong. Try again in a moment."
}
diff --git a/packages/vitnode/src/tanstack/moderation/index.ts b/packages/vitnode/src/tanstack/moderation/index.ts
index d4589a643..54b39c81c 100644
--- a/packages/vitnode/src/tanstack/moderation/index.ts
+++ b/packages/vitnode/src/tanstack/moderation/index.ts
@@ -1,2 +1,8 @@
export { MODCP_NAMESPACES } from "./namespaces";
-export { ReportButton, type ReportButtonProps } from "./report-button";
+export {
+ REPORT_NAMESPACES,
+ ReportButton,
+ type ReportButtonProps,
+ ReportDialog,
+ type ReportDialogProps,
+} from "./report-button";
diff --git a/packages/vitnode/src/tanstack/moderation/report-button.test.tsx b/packages/vitnode/src/tanstack/moderation/report-button.test.tsx
new file mode 100644
index 000000000..1bbffc499
--- /dev/null
+++ b/packages/vitnode/src/tanstack/moderation/report-button.test.tsx
@@ -0,0 +1,111 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRouter,
+ RouterProvider,
+} from "@tanstack/react-router";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { sessionQueryOptions } from "../auth/session-query";
+import { configureIntl, resetIntlRuntime } from "../i18n";
+import { ReportButton, ReportDialog } from "./report-button";
+
+const TARGET = { id: "1", pluginId: "@vitnode/forum", type: "forum.topic" };
+
+const mount = (ui: React.ReactNode) => {
+ const queryClient = new QueryClient();
+ queryClient.setQueryData(sessionQueryOptions().queryKey, { user: null });
+
+ const router = createRouter({
+ history: createMemoryHistory({ initialEntries: ["/topics/1?page=2"] }),
+ routeTree: createRootRoute({ component: () => ui }),
+ });
+
+ render(
+
+
+ ,
+ );
+};
+
+beforeEach(() => {
+ configureIntl({
+ fetchMessages: async () =>
+ await Promise.resolve({ locale: "en", messages: {} }),
+ i18n: { defaultLocale: "en", locales: [{ code: "en", name: "English" }] },
+ });
+});
+
+afterEach(() => {
+ resetIntlRuntime();
+});
+
+describe("ReportButton", () => {
+ it("names the item it reports and points at its description", async () => {
+ mount(
+ <>
+
Topic
+
+ >,
+ );
+
+ const button = await screen.findByRole("button", {
+ name: "core.moderation.report.title",
+ });
+ expect(button.getAttribute("aria-haspopup")).toBe("dialog");
+ expect(button.getAttribute("aria-describedby")).toBe("topic-1-title");
+ });
+
+ it("opens a dialog that sends a guest to sign in and back", async () => {
+ mount();
+ expect(screen.queryByRole("dialog")).toBeNull();
+
+ fireEvent.click(
+ await screen.findByRole("button", {
+ name: "core.moderation.report.title",
+ }),
+ );
+
+ const dialog = await screen.findByRole("dialog");
+ expect(dialog.textContent).toContain("core.moderation.report.signed_out");
+ const signIn = screen.getByRole("link", {
+ name: "core.moderation.report.sign_in",
+ });
+ const href = new URL(
+ signIn.getAttribute("href") ?? "",
+ "https://vitnode.invalid",
+ );
+ expect(href.pathname).toBe("/login");
+ expect(href.searchParams.get("returnTo")).toBe("/topics/1?page=2");
+ });
+});
+
+describe("ReportDialog", () => {
+ it.each([
+ [false, 0],
+ [true, 1],
+ ])("with open=%s renders %i dialogs", async (open, dialogs) => {
+ mount(
+ <>
+
+ undefined}
+ open={open}
+ target={TARGET}
+ typeLabel="topic"
+ />
+ >,
+ );
+ // Both load the same messages, so the button showing means the dialog has too.
+ await screen.findByRole("button", { name: "core.moderation.report.title" });
+ if (open) await screen.findByRole("dialog");
+
+ expect(screen.queryAllByRole("dialog")).toHaveLength(dialogs);
+ });
+});
diff --git a/packages/vitnode/src/tanstack/moderation/report-button.tsx b/packages/vitnode/src/tanstack/moderation/report-button.tsx
index 6381eda74..078735600 100644
--- a/packages/vitnode/src/tanstack/moderation/report-button.tsx
+++ b/packages/vitnode/src/tanstack/moderation/report-button.tsx
@@ -3,6 +3,8 @@ import { FlagIcon } from "lucide-react";
import React from "react";
import { useTranslations } from "use-intl";
+import type { ContentTarget } from "@/content/target";
+
import { Button, buttonVariants } from "@/components/ui/button";
import {
Dialog,
@@ -21,44 +23,115 @@ const ReportDialogContent = React.lazy(
async () => await import("@/views/moderation/report/report-dialog-content"),
);
+/** The message namespaces the report button and dialog render with. */
export const REPORT_NAMESPACES = ["core.global", "core.moderation"] as const;
-export interface ReportButtonProps {
- className?: string;
+interface ReportTargetProps {
/** The locale the reported content was shown in, for moderators. */
locale?: null | string;
- /** The plugin that registered the report target. */
- pluginId: string;
/** The locale the visitor asked for, when the content fell back to another. */
requestedLocale?: null | string;
- /** The reported item's id. */
- targetId: number | string;
- /** The report target type - for a content type with reports enabled, its id. */
- targetType: string;
- /** What the item is called, already translated, for the dialog title ("Report {type}"). */
+ /**
+ * The reported record. For a content type with `reports` enabled, build it
+ * with `contentTarget` from `@vitnode/core/content`.
+ */
+ target: ContentTarget;
+ /** What the item is called, already translated, for "Report {type}". */
typeLabel: string;
}
-const ReportButtonInner = ({
- className,
+export interface ReportDialogProps extends ReportTargetProps {
+ onOpenChange: (open: boolean) => void;
+ open: boolean;
+}
+
+export interface ReportButtonProps extends ReportTargetProps {
+ className?: string;
+ /**
+ * The id of the element that names the item, such as its title or its
+ * author. Screen readers read it after the button's label, so a list of
+ * report buttons is told apart.
+ */
+ describedBy?: string;
+}
+
+const ReportDialogInner = ({
locale = null,
- pluginId,
+ onOpenChange,
+ open,
requestedLocale = null,
- targetId,
- targetType,
+ target,
typeLabel,
-}: ReportButtonProps) => {
+}: ReportDialogProps) => {
const t = useTranslations("core.moderation.report");
const session = useSessionQuery();
- const [open, setOpen] = React.useState(false);
const returnTo = useRouterState({
select: state => returnToFor(state.location),
});
const signedIn = Boolean(session.data?.user);
+ return (
+
+ );
+};
+
+const ReportButtonInner = ({
+ className,
+ describedBy,
+ ...dialog
+}: ReportButtonProps) => {
+ const t = useTranslations("core.moderation.report");
+ const [open, setOpen] = React.useState(false);
+
return (
<>
-
+
>
);
};
+/**
+ * The "report this" dialog on its own, controlled by you. Use it when the
+ * trigger is yours - a menu item, a keyboard shortcut - or to share one dialog
+ * across a long list instead of mounting one per row. Guests are offered a
+ * sign-in link instead of the form, and the form loads only once the dialog
+ * opens.
+ *
+ * @example
+ * ```tsx
+ * import type { ContentTarget } from "@vitnode/core/content";
+ *
+ * import { ReportDialog } from "@vitnode/core/tanstack/moderation";
+ *
+ * const [reporting, setReporting] = useState(null);
+ *
+ * {reporting ? (
+ * {
+ * if (!open) setReporting(null);
+ * }}
+ * open
+ * target={reporting}
+ * typeLabel={t("report_type")}
+ * />
+ * ) : null}
+ * ```
+ */
+export const ReportDialog = (props: ReportDialogProps) => (
+
+
+
+
+
+);
+
/**
* A flag button that opens the "report this" dialog for one item. Guests are
* offered a sign-in link instead of the form. Loads its own messages and the
@@ -125,26 +190,27 @@ const ReportButtonInner = ({
*
* The target must be registered on the API - a content type with `reports`
* enabled, or a plugin's `moderation.reportTargets` - or the report is refused.
+ * The server re-checks everything: the reporter must be signed in and able to
+ * read the target.
*
* @example
* ```tsx
+ * import { contentTarget } from "@vitnode/core/content";
* import { ReportButton } from "@vitnode/core/tanstack/moderation";
*
+ * import { commentContentType } from "@/content/comment";
+ * import { manifest } from "@/manifest.gen";
+ *
* ;
* ```
*/
export const ReportButton = (props: ReportButtonProps) => (
-
-
- }
+ fallback={}
>
diff --git a/packages/vitnode/src/views/moderation/moderation-mutations.test.ts b/packages/vitnode/src/views/moderation/moderation-mutations.test.ts
new file mode 100644
index 000000000..479acf94c
--- /dev/null
+++ b/packages/vitnode/src/views/moderation/moderation-mutations.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { createReport } from "./moderation-mutations";
+
+const input = {
+ description: null,
+ locale: null,
+ pluginId: "@vitnode/forum",
+ reason: "spam",
+ targetId: "1",
+ targetType: "forum.topic",
+} as const;
+
+const respond = (status: number, body: unknown) => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ async () =>
+ await Promise.resolve(
+ new Response(JSON.stringify(body), {
+ headers: { "content-type": "application/json" },
+ status,
+ }),
+ ),
+ ),
+ );
+};
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("createReport", () => {
+ it.each([
+ [401, { message: "Sign in first." }, "unauthenticated"],
+ [400, { message: "Invalid" }, "validation"],
+ [404, { error: "not_found" }, "not_found"],
+ [409, { error: "duplicate" }, "duplicate"],
+ [429, { error: "rate_limited" }, "rate_limited"],
+ ])("turns a %i (%j) into a %s failure", async (status, body, failure) => {
+ respond(status, body);
+
+ expect(await createReport(input)).toEqual({ failure, ok: false });
+ });
+
+ it("returns the new report's id", async () => {
+ respond(201, { id: 7 });
+
+ expect(await createReport(input)).toEqual({ data: { id: 7 }, ok: true });
+ });
+});
diff --git a/packages/vitnode/src/views/moderation/moderation-mutations.ts b/packages/vitnode/src/views/moderation/moderation-mutations.ts
index 83ba158a1..ee59b884f 100644
--- a/packages/vitnode/src/views/moderation/moderation-mutations.ts
+++ b/packages/vitnode/src/views/moderation/moderation-mutations.ts
@@ -19,6 +19,7 @@ const REPORTED_FAILURES = [
] as const;
export type ModerationFailure =
+ | "unauthenticated"
| "unknown"
| "validation"
| (typeof REPORTED_FAILURES)[number];
@@ -45,6 +46,7 @@ const failureOf = async (response: Response): Promise => {
const reported = await reportedFailureOf(response);
if (reported) return reported;
if (response.status === 400) return "validation";
+ if (response.status === 401) return "unauthenticated";
if (response.status === 403) return "forbidden";
if (response.status === 404) return "not_found";
diff --git a/packages/vitnode/src/views/moderation/report/report-dialog-content.tsx b/packages/vitnode/src/views/moderation/report/report-dialog-content.tsx
index 1aba88467..e039a324f 100644
--- a/packages/vitnode/src/views/moderation/report/report-dialog-content.tsx
+++ b/packages/vitnode/src/views/moderation/report/report-dialog-content.tsx
@@ -78,6 +78,7 @@ export const ReportDialogContent = ({
result.failure === "duplicate" ||
result.failure === "not_found" ||
result.failure === "rate_limited" ||
+ result.failure === "unauthenticated" ||
result.failure === "validation"
? result.failure
: "unknown";
diff --git a/plugins/blog/src/pages/post-page.tsx b/plugins/blog/src/pages/post-page.tsx
index 1c882ce17..58b7a30d6 100644
--- a/plugins/blog/src/pages/post-page.tsx
+++ b/plugins/blog/src/pages/post-page.tsx
@@ -9,6 +9,7 @@ import {
AlertDescription,
AlertTitle,
} from "@vitnode/core/components/ui/alert";
+import { contentTarget } from "@vitnode/core/content";
import { resolveImageAlt } from "@vitnode/core/lib/files/resolve-alt";
import {
contentDeliveryPage,
@@ -78,10 +79,12 @@ const BlogPostPage = ({
) : null}
diff --git a/plugins/forum/src/content/comment.ts b/plugins/forum/src/content/comment.ts
index a3d20b2b3..5a5b98ab6 100644
--- a/plugins/forum/src/content/comment.ts
+++ b/plugins/forum/src/content/comment.ts
@@ -27,6 +27,8 @@ export const commentContentType = defineContentType({
publication: true,
visibility: true,
+ // A comment has no title of its own, so moderators read its body.
+ reports: { snapshotFields: ["body"] },
editorial: { revisions: { retention: 20 } },
indexes: [{ on: ["topic", "parent", "createdAt"] }],
diff --git a/plugins/forum/src/content/topic.ts b/plugins/forum/src/content/topic.ts
index 043565027..6ac3d5fac 100644
--- a/plugins/forum/src/content/topic.ts
+++ b/plugins/forum/src/content/topic.ts
@@ -16,6 +16,7 @@ export const topicContentType = defineContentType({
publication: true,
visibility: true,
+ reports: true,
editorial: { revisions: { retention: 20 } },
indexes: [{ on: ["forum", "publishedAt"] }],
diff --git a/plugins/forum/src/locales/en.json b/plugins/forum/src/locales/en.json
index eedbfb5a5..b3de652be 100644
--- a/plugins/forum/src/locales/en.json
+++ b/plugins/forum/src/locales/en.json
@@ -187,6 +187,7 @@
"new": "New topic",
"posted": "Posted",
"actions": "Topic actions",
+ "report_type": "topic",
"created": {
"title": "Topic posted",
"desc": "Your topic is live. Now we wait for the comments to roll in."
@@ -242,6 +243,7 @@
"edited_label": "edited",
"reply": "Reply",
"edit": "Edit",
+ "report_type": "comment",
"someone": "this comment",
"removed": "This comment was removed.",
"show_answers": "{count, plural, one {Show # reply} other {Show # replies}}",
@@ -287,6 +289,9 @@
"@vitnode/forum:topic:can_hide": "Hide and unhide topics",
"@vitnode/forum:topic:can_restore": "Restore an earlier version of a topic",
"@vitnode/forum:topic:can_approve": "Approve or decline topics waiting for review",
+ "@vitnode/forum:topic:can_view_reports": "View topic reports",
+ "@vitnode/forum:topic:can_manage_reports": "Claim, assign and resolve topic reports",
+ "@vitnode/forum:topic:can_view_hidden": "Open hidden topics",
"@vitnode/forum:comment": "Comments",
"@vitnode/forum:comment:can_view": "View comments",
"@vitnode/forum:comment:can_create": "Create comments",
@@ -296,6 +301,9 @@
"@vitnode/forum:comment:can_hide": "Hide and unhide comments",
"@vitnode/forum:comment:can_restore": "Restore an earlier version of a comment",
"@vitnode/forum:comment:can_approve": "Approve or decline comments waiting for review",
+ "@vitnode/forum:comment:can_view_reports": "View comment reports",
+ "@vitnode/forum:comment:can_manage_reports": "Claim, assign and resolve comment reports",
+ "@vitnode/forum:comment:can_view_hidden": "Open hidden comments",
"@vitnode/forum:forum_forums": "Forums",
"@vitnode/forum:forum_forums:can_view": "View forums",
"@vitnode/forum:forum_forums:can_create": "Create forums",
diff --git a/plugins/forum/src/pages/thread.tsx b/plugins/forum/src/pages/thread.tsx
index 6dd159493..9a380e2dc 100644
--- a/plugins/forum/src/pages/thread.tsx
+++ b/plugins/forum/src/pages/thread.tsx
@@ -8,10 +8,11 @@ import {
EmptyMedia,
EmptyTitle,
} from "@vitnode/core/components/ui/empty";
-import { zodRichTextDocument } from "@vitnode/core/content";
+import { contentTarget, zodRichTextDocument } from "@vitnode/core/content";
import { useSessionQuery } from "@vitnode/core/tanstack/auth";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { HrefLink } from "@vitnode/core/tanstack/link";
+import { ReportButton } from "@vitnode/core/tanstack/moderation";
import { cn } from "cn";
import {
ChevronDownIcon,
@@ -33,7 +34,7 @@ import { toast } from "sonner";
import { useTranslations } from "use-intl";
import { z } from "zod";
-import { FORUM_COMMENT_MAX_DEPTH } from "@/content/comment";
+import { commentContentType, FORUM_COMMENT_MAX_DEPTH } from "@/content/comment";
import { manifest } from "@/manifest.gen";
import type { CommentComposerProps } from "./comment-composer";
@@ -144,6 +145,7 @@ const CommentBody = ({
actions.currentUserId !== null &&
comment.author?.id === actions.currentUserId;
const canAnswer = actions.canComment && node.depth < FORUM_COMMENT_MAX_DEPTH;
+ const authorId = `${commentAnchorId(comment.slug)}-author`;
const edited =
comment.publishedAt !== null &&
new Date(comment.updatedAt).getTime() -
@@ -154,7 +156,9 @@ const CommentBody = ({