fix(bugreport): admin toggle now actually blocks feedback submissions

The "Bug reports" admin toggle had no effect. The form on `/bugreport`
posts to `/api/feedback`, which was the wrong endpoint for the gate —
it lived only on the legacy `/api/bugreport` route the form does not
call. Users still submitted into the local feedback inbox after the
admin disabled the feature, and the prior commit message claimed
"the report button gracefully disappears" was untrue.

- `/api/bugreport/status` now also returns `enabled` (defaults true
  when the column has never been written) so the page can decide
  whether to render the form at all.
- `/api/feedback` POST checks `bugReportEnabled` and returns 503 with
  a readable error when the admin has switched the feature off, so a
  scripted client cannot bypass the UI by hitting the endpoint directly.
- `/bugreport` page now hides the form and shows the disabled-banner
  copy when `enabled === false`.
- New i18n keys for the disabled state in DE + EN.
- Vitest covers the gate: blocked when off, allowed when on, allowed
  when row absent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 01:31:11 +02:00
1 parent 4aeb8c904e
commit 31959e436e
6 files changed
+162 -5

No files matched your search

+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+9 -3
View File
@@ -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" });
});
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => 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);
});
});
+12
View File
@@ -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;
+27
View File
@@ -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 (
<div className="mx-auto w-full max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("bugreport.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("bugreport.subtitle")}
</p>
</div>
<div className="bg-card border-border border-l-dracula-orange flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<Info className="text-dracula-orange mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="text-foreground font-medium">
{t("bugreport.disabledTitle")}
</p>
<p className="text-muted-foreground mt-1">
{t("bugreport.disabledBody")}
</p>
</div>
</div>
</div>
);
}
return (
<div className="mx-auto w-full max-w-6xl space-y-6">
<div>