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 ( + + + + + {t("title", { type: typeLabel })} + + + {signedIn ? t("desc") : t("signed_out")} + + + + {signedIn ? ( + + + + + } + > + { + onOpenChange(false); + }} + pluginId={target.pluginId} + requestedLocale={requestedLocale} + targetId={target.id} + targetType={target.type} + /> + + ) : ( +
+ + {t("sign_in")} + +
+ )} +
+
+ ); +}; + +const ReportButtonInner = ({ + className, + describedBy, + ...dialog +}: ReportButtonProps) => { + const t = useTranslations("core.moderation.report"); + const [open, setOpen] = React.useState(false); + return ( <> - - - - - {t("title", { type: typeLabel })} - - - {signedIn ? t("desc") : t("signed_out")} - - - - {signedIn ? ( - - - - - } - > - { - setOpen(false); - }} - pluginId={pluginId} - requestedLocale={requestedLocale} - targetId={String(targetId)} - targetType={targetType} - /> - - ) : ( -
- - {t("sign_in")} - -
- )} -
-
+ ); }; +/** + * 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 = ({
- + + + @@ -227,7 +231,18 @@ const CommentBody = ({
)} diff --git a/plugins/forum/src/pages/topic-page.tsx b/plugins/forum/src/pages/topic-page.tsx index 4736048cf..04790318a 100644 --- a/plugins/forum/src/pages/topic-page.tsx +++ b/plugins/forum/src/pages/topic-page.tsx @@ -11,6 +11,7 @@ import { } from "@vitnode/core/components/ui/breadcrumb"; import { buttonVariants } from "@vitnode/core/components/ui/button"; import { + contentTarget, richTextToPlainText, zodRichTextDocument, } from "@vitnode/core/content"; @@ -20,6 +21,7 @@ import { contentPageItem } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { HrefLink } from "@vitnode/core/tanstack/link"; import { localeAlternateLinks } from "@vitnode/core/tanstack/metadata"; +import { ReportButton } from "@vitnode/core/tanstack/moderation"; import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; import { useRecordView } from "@vitnode/core/tanstack/search"; import { PencilIcon } from "lucide-react"; @@ -202,8 +204,11 @@ export const route = defineRoute({ }, }); -/** Rendered in the browser from the session, so a cached page stays shared. */ -const TopicOwnerActions = ({ +/** + * Rendered in the browser from the session, so a cached page stays shared: the + * author gets Edit, everyone else gets Report. + */ +const TopicViewerActions = ({ author, topicId, }: { @@ -214,18 +219,24 @@ const TopicOwnerActions = ({ const { data: session } = useSessionQuery(); const userId = session?.user?.id; - if (userId === undefined || author === null || author.id !== userId) { - return null; + if (userId !== undefined && author !== null && author.id === userId) { + return ( + + + ); } return ( - - + ); }; @@ -328,7 +339,7 @@ const TopicPage = ({