diff --git a/messages/de.json b/messages/de.json index 52c22e357..0e35b2b9c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1609,7 +1609,10 @@ "categoryOther": "Sonstiges Feedback", "bugTitlePlaceholder": "Kurze Zusammenfassung (optional)", "githubEscalationNote": "Dein Feedback wird lokal gespeichert. Wenn der Administrator die GitHub-Integration aktiviert, kann es auf Wunsch als öffentliches Issue veröffentlicht werden.", - "internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar." + "internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar.", + "disabledTitle": "Bug-Reports sind deaktiviert", + "disabledBody": "Der Administrator hat Bug-Reports auf diesem Server deaktiviert.", + "disabledError": "Bug-Reports sind vom Administrator deaktiviert." }, "notifications": { "title": "Benachrichtigungen", diff --git a/messages/en.json b/messages/en.json index 7b33c4cf8..87dbb3f06 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1609,7 +1609,10 @@ "categoryOther": "Other feedback", "bugTitlePlaceholder": "Short summary (optional)", "githubEscalationNote": "Your feedback is stored locally. If the admin enables GitHub integration, it can be promoted to a public issue on request.", - "internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server." + "internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server.", + "disabledTitle": "Bug reports are turned off", + "disabledBody": "The administrator has disabled bug reports on this server.", + "disabledError": "Bug reports are disabled by the administrator." }, "notifications": { "title": "Notifications", diff --git a/src/app/api/bugreport/status/route.ts b/src/app/api/bugreport/status/route.ts index d933900b3..1f5d56fac 100644 --- a/src/app/api/bugreport/status/route.ts +++ b/src/app/api/bugreport/status/route.ts @@ -29,7 +29,11 @@ export const GET = apiHandler(async () => { const appSettings = await prisma.appSettings.findUnique({ where: { id: "singleton" }, - select: { githubIssueTokenEncrypted: true, githubIssueRepo: true }, + select: { + githubIssueTokenEncrypted: true, + githubIssueRepo: true, + bugReportEnabled: true, + }, }); const hasToken = Boolean( @@ -39,11 +43,13 @@ export const GET = apiHandler(async () => { appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO, ); const configured = hasToken && hasRepo; + // Default ON when the column has never been written. + const enabled = appSettings?.bugReportEnabled !== false; annotate({ action: { name: "bugreport.status" }, - meta: { configured, has_token: hasToken, has_repo: hasRepo }, + meta: { configured, enabled, has_token: hasToken, has_repo: hasRepo }, }); - return apiSuccess({ configured, isAdmin: user.role === "ADMIN" }); + return apiSuccess({ configured, enabled, isAdmin: user.role === "ADMIN" }); }); diff --git a/src/app/api/feedback/__tests__/route.test.ts b/src/app/api/feedback/__tests__/route.test.ts new file mode 100644 index 000000000..48af20465 --- /dev/null +++ b/src/app/api/feedback/__tests__/route.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ + user: { id: "u-1", email: "test@example.com", role: "USER" }, + session: { id: "s-1" }, + })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn() }, + feedback: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn(async () => undefined), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +function jsonRequest(body: unknown): Request { + return new Request("http://localhost/api/feedback", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/feedback bug-report toggle gate", () => { + it("returns 503 when bugReportEnabled is false", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + bugReportEnabled: false, + } as never); + + const res = await POST( + jsonRequest({ + category: "BUG", + subject: "test", + description: "Anything at least ten characters long.", + }), + ); + expect(res.status).toBe(503); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/disabled/i); + // Must not even attempt to create the row when the toggle is off. + expect(prisma.feedback.create).not.toHaveBeenCalled(); + }); + + it("creates feedback when bugReportEnabled is true", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + bugReportEnabled: true, + } as never); + vi.mocked(prisma.feedback.create).mockResolvedValue({ + id: "fb-1", + createdAt: new Date(), + category: "BUG", + status: "NEW", + } as never); + + const res = await POST( + jsonRequest({ + category: "BUG", + subject: "test", + description: "Anything at least ten characters long.", + }), + ); + expect(res.status).toBe(201); + expect(prisma.feedback.create).toHaveBeenCalledTimes(1); + }); + + it("creates feedback when bugReportEnabled has never been written (default ON)", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + vi.mocked(prisma.feedback.create).mockResolvedValue({ + id: "fb-2", + createdAt: new Date(), + category: "BUG", + status: "NEW", + } as never); + + const res = await POST( + jsonRequest({ + category: "BUG", + subject: "test", + description: "Anything at least ten characters long.", + }), + ); + expect(res.status).toBe(201); + expect(prisma.feedback.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts index fb6a138e7..c3923b684 100644 --- a/src/app/api/feedback/route.ts +++ b/src/app/api/feedback/route.ts @@ -23,6 +23,18 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many submissions — please try again later", 429); } + // The admin "Bug reports" toggle gates *every* feedback submission, not + // just the GitHub-promotion path. Previously the gate only lived in the + // legacy `/api/bugreport` route which the form does not even call — + // so flipping the toggle off had no effect on user-visible behaviour. + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { bugReportEnabled: true }, + }); + if (settings && settings.bugReportEnabled === false) { + return apiError("Bug reports are disabled by the administrator", 503); + } + const { data: body, error: jsonError } = await safeJson(request); if (jsonError) return jsonError; diff --git a/src/app/bugreport/page.tsx b/src/app/bugreport/page.tsx index 588e0e8f0..a62291a7a 100644 --- a/src/app/bugreport/page.tsx +++ b/src/app/bugreport/page.tsx @@ -25,6 +25,7 @@ import { queryKeys } from "@/lib/query-keys"; interface BugReportStatus { configured: boolean; + enabled: boolean; isAdmin: boolean; } @@ -115,6 +116,32 @@ export default function BugReportPage() { ); } + if (status && status.enabled === false) { + return ( +
+
+

+ {t("bugreport.title")} +

+

+ {t("bugreport.subtitle")} +

+
+
+ +
+

+ {t("bugreport.disabledTitle")} +

+

+ {t("bugreport.disabledBody")} +

+
+
+
+ ); + } + return (