diff --git a/AGENTS.md b/AGENTS.md
index 7ad2093e92a5..344df1f10581 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,6 +24,27 @@ to test; `email`, `types`, and `vite-plugins` are consumed from source, so they
`apps/storybook` has no unit tests by policy (UI is covered by Playwright). Keep new packages on this
matrix or document the exception here.
+### Shared dependency versions (pnpm catalog)
+
+Every dependency used by **two or more** workspaces is pinned once in the `catalog:` block of
+`pnpm-workspace.yaml`, and each `package.json` references it as `"catalog:"` instead of a version:
+
+```json
+"devDependencies": { "typescript": "catalog:", "vitest": "catalog:" }
+```
+
+So bumping a shared dependency means editing the catalog entry — never a `package.json`. That is the
+whole point: `nodeLinker: hoisted` hides a version split until it breaks, so `apps/web` was typing a
+redis 5 client with redis 4's `RedisClientType` and one package was building on a different Vite major
+than the other fourteen. Deps with a single consumer deliberately stay in their own `package.json`.
+
+`pnpm lint` runs `scripts/check-catalog.mjs`, which fails if a workspace declares a literal version for
+a catalogued name, or if a dependency is declared by 2+ workspaces without being catalogued. A peer
+dependency *range* is exempt — it is a compatibility declaration for consumers, not an install pin, so
+it may legitimately be looser than the catalog (`packages/survey-ui` declares react `^19.0.0` while
+pinning 19.2.6 to build against). Adding a new package needs no wiring: the check resolves the
+workspace globs from `pnpm-workspace.yaml` itself.
+
### Survey Packages Build & Cache
The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the built bundle is copied to `apps/web/public/js/`. The Next.js app imports from `dist/`, **not** the source files. This means:
diff --git a/README.md b/README.md
index 88dffb1c7369..2cbb36971dc5 100644
--- a/README.md
+++ b/README.md
@@ -201,7 +201,7 @@ Let's have a chat about your survey needs and get you started.
## 🔒 Security
-We take security very seriously. If you come across any security vulnerabilities, please disclose them by sending an email to security@formbricks.com. We appreciate your help in making our platform as secure as possible and are committed to working with you to resolve any issues quickly and efficiently. See [`SECURITY.md`](./SECURITY.md) for more information.
+We take security very seriously. If you come across any security vulnerabilities, please disclose them by sending an email to security@formbricks.com. We appreciate your help in making our platform as secure as possible and are committed to working with you to resolve any issues quickly and efficiently. Please note that we do not offer bug bounties or any other payment for security reports, but we are happy to credit you in the release notes for the fix on request. See [`SECURITY.md`](./SECURITY.md) for more information.
diff --git a/SECURITY.md b/SECURITY.md
index c1c3171cdcec..90d420c84b00 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,7 +1,7 @@
# Security Policy of Formbricks
-This is Formbrick's security policy. Please reach out to us
-on Github or, if privately, via
+This is the Formbricks security policy. Please report vulnerabilities
+privately via rather than in public.
## Introduction
@@ -27,6 +27,8 @@ To understand and bolster our security stature, Formbricks undertakes:
Please do not use attacks on physical security, social engineering, distributed denial of service, spam or applications of third parties.
+> **Formbricks does not offer bug bounties.** We do not pay for vulnerability reports of any kind. Public credit for your finding is available on request — see [D. Bug Bounties and Credit](#d-bug-bounties-and-credit).
+
### **A. When to Report a Vulnerability**
We invite you to report if:
@@ -51,7 +53,8 @@ In the interest of responsibly managing vulnerabilities, please adhere to the fo
> Do not reveal the problem to others until it has been resolved.
1. **Send a Detailed Report**:
- - Raise a security report on [Github](https://github.com/formbricks/formbricks/issues/new/choose) or send an email to [security@formbricks.com](mailto:security@formbricks.com).
+ - Send an email to [security@formbricks.com](mailto:security@formbricks.com).
+ - Please do not open a GitHub issue for a vulnerability. The issue tracker is public, so filing there discloses the problem before a fix exists — which is what the line above asks you to avoid.
- Include:
- Problem description.
- Detailed, reproducible steps, with screenshots where possible.
@@ -65,11 +68,23 @@ In the interest of responsibly managing vulnerabilities, please adhere to the fo
- A project maintainer may engage with you for additional details or clarification.
- We appreciate your patience as we explore the reported item, verify its authenticity, and ascertain the existence of a vulnerability.
+### **D. Bug Bounties and Credit**
+
+Formbricks does not run a bug bounty program, and we want to be upfront about that before you invest your time:
+
+- We do not pay bounties, rewards, gift cards, or goodwill payments for security reports. There are no exceptions, and this is not decided case by case.
+- We have offered both a bounty and one-off payments in the past. The result was a sharp increase in low-quality and automated reports rather than better ones, so we stopped. It is a settled policy rather than a question of budget.
+- Please do not attach an invoice, a payment request, or a payment condition to a report. We will still read and act on the report, but the answer on payment will be no.
+
+What we do offer is **credit**. If you would like to be named, tell us in your report or at any point before the fix ships, and we will mention you in the release notes for the fix.
+
+None of this changes how seriously we treat your report. A well-written vulnerability report is real work, and we are genuinely grateful for it — we simply pay it back in credit and a fast fix rather than in money.
+
---
### Please Read the below carefully
-If you have followed the instructions above, we will **not** take any legal action against you in regard to the report,
-We will handle your report with strict confidentiality, and not pass on your personal details to third parties without your permission, We will keep you informed of the progress towards resolving the problem, In the public information concerning the problem reported, we will give your name as the discoverer of the problem (unless you desire otherwise).
+If you have followed the instructions above, we will **not** take any legal action against you in regard to the report.
+We will handle your report with strict confidentiality and will not pass on your personal details to third parties without your permission. We will keep you informed of the progress towards resolving the problem. In the public information concerning the problem reported, we will name you as the discoverer of the problem only if you have asked to be credited. Otherwise, we will not publish your identity.
We, at Formbricks, wish to express our gratitude towards all individuals who assist us in fortifying our security posture. Your responsible disclosure and cooperation enable us to elevate our security protocols, safeguarding our platform and data therein.
diff --git a/apps/storybook/package.json b/apps/storybook/package.json
index ac62c4f6cdfb..700cc9422483 100644
--- a/apps/storybook/package.json
+++ b/apps/storybook/package.json
@@ -13,22 +13,22 @@
},
"devDependencies": {
"@chromatic-com/storybook": "5.0.2",
- "@eslint/js": "9.39.5",
+ "@eslint/js": "catalog:",
"@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "10.3.6",
"@storybook/addon-docs": "10.3.6",
"@storybook/addon-links": "10.3.6",
"@storybook/addon-onboarding": "10.3.6",
- "@storybook/react-vite": "10.3.6",
- "@tailwindcss/vite": "4.2.4",
- "@vitejs/plugin-react": "5.1.4",
- "eslint-plugin-react-hooks": "7.1.1",
+ "@storybook/react-vite": "catalog:",
+ "@tailwindcss/vite": "catalog:",
+ "@vitejs/plugin-react": "catalog:",
+ "eslint-plugin-react-hooks": "catalog:",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.3.6",
- "globals": "16.5.0",
- "storybook": "10.3.6",
- "typescript": "5.9.3",
- "typescript-eslint": "8.63.0",
- "vite": "7.3.5"
+ "globals": "catalog:",
+ "storybook": "catalog:",
+ "typescript": "catalog:",
+ "typescript-eslint": "catalog:",
+ "vite": "catalog:"
}
}
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/__mocks__/delete-response-files.mock.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/__mocks__/delete-response-files.mock.ts
new file mode 100644
index 000000000000..d23116804b64
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/__mocks__/delete-response-files.mock.ts
@@ -0,0 +1,11 @@
+import { vi } from "vitest";
+
+/**
+ * Storage boundary for the survey-reset tests. Kept in `__mocks__` (per AGENTS.md) so the `vi.mock`
+ * call is hoisted by the import order rather than by a bare `vi.mock` inside each spec.
+ */
+export const deleteResponseFileUrls = vi.fn<(fileUrls: string[], workspaceId?: string) => Promise>();
+
+vi.mock("@/modules/storage/lib/delete-response-files", () => ({
+ deleteResponseFileUrls,
+}));
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/__mocks__/survey-reset.mock.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/__mocks__/survey-reset.mock.ts
new file mode 100644
index 000000000000..9aaa1818c660
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/__mocks__/survey-reset.mock.ts
@@ -0,0 +1,55 @@
+import { TResponseData } from "@formbricks/types/responses";
+import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
+import { TSurveyElementTypeEnum, TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
+import { TSurvey } from "@formbricks/types/surveys/types";
+
+export const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
+export const workspaceId = "u8qa6u0tlxb6160pi2jb8s4p";
+
+export const fileUploadElement: TSurveyFileUploadElement = {
+ id: "y3ydd3td2iq09wa599cxo1me",
+ type: TSurveyElementTypeEnum.FileUpload,
+ headline: { default: "Upload your file" },
+ required: false,
+ allowMultipleFiles: true,
+};
+
+export const fileUploadBlock: TSurveyBlock = {
+ id: "wq0m4wvvvhmzrxmnzmr6mkuz",
+ name: "File upload block",
+ elements: [fileUploadElement],
+};
+
+/**
+ * `collectSurveyResponseFileUrls` reads exactly these three fields off the survey, so the fixtures
+ * declare only those — fully typed, so a wrong block or element shape fails typecheck. The single cast
+ * to `TSurvey` lives in the mock helper that hands them to `getSurvey`.
+ */
+export type SurveyFileUploadFields = Pick;
+
+export const surveyWithFileUpload: SurveyFileUploadFields = {
+ blocks: [fileUploadBlock],
+ questions: [],
+ workspaceId,
+};
+
+export const surveyWithoutFileUpload: SurveyFileUploadFields = {
+ blocks: [],
+ questions: [],
+ workspaceId,
+};
+
+export const storageUrl = (fileName: string) =>
+ `https://example.com/storage/${workspaceId}/private/${fileName}`;
+
+/** One response row as `collectSurveyResponseFileUrls` selects it (`id`, `createdAt`, `data`). */
+export type ScannedResponse = { id: string; createdAt: Date; data: TResponseData };
+
+/** Fixed epoch offsets keep the fixtures deterministic and the keyset order predictable. */
+export const scanTimestamp = (index: number) => new Date(Date.UTC(2026, 0, 1) + index * 1000);
+
+export const responseWithFiles = (id: string, fileNames: string[], index = 0): ScannedResponse => ({
+ id,
+ createdAt: scanTimestamp(index),
+ data: { [fileUploadElement.id]: fileNames.map(storageUrl) },
+});
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/__mocks__/survey-service.mock.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/__mocks__/survey-service.mock.ts
new file mode 100644
index 000000000000..23096b2538c0
--- /dev/null
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/__mocks__/survey-service.mock.ts
@@ -0,0 +1,13 @@
+import { vi } from "vitest";
+import type { getSurvey as getSurveyImpl } from "@/lib/survey/service";
+
+/**
+ * Survey-read boundary for the survey-reset tests. Kept in `__mocks__` (per AGENTS.md) so the
+ * `vi.mock` call is hoisted by import order rather than by a bare `vi.mock` in each spec. Typed off
+ * the real export, so `mockResolvedValue` is checked against `Promise`.
+ */
+export const getSurvey = vi.fn();
+
+vi.mock("@/lib/survey/service", () => ({
+ getSurvey,
+}));
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/survey.test.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/survey.test.ts
index 967121aae0db..a05411bdaa51 100644
--- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/survey.test.ts
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/survey.test.ts
@@ -1,38 +1,55 @@
+import { deleteResponseFileUrls } from "./__mocks__/delete-response-files.mock";
+import {
+ ScannedResponse,
+ SurveyFileUploadFields,
+ fileUploadElement,
+ responseWithFiles,
+ scanTimestamp,
+ storageUrl,
+ surveyId,
+ surveyWithFileUpload,
+ surveyWithoutFileUpload,
+ workspaceId,
+} from "./__mocks__/survey-reset.mock";
+import { getSurvey } from "./__mocks__/survey-service.mock";
+import { prisma } from "@/lib/__mocks__/database";
import { beforeEach, describe, expect, test, vi } from "vitest";
-import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/prisma";
import { PrismaErrorType } from "@formbricks/database/types/error";
+import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
+import { TSurvey } from "@formbricks/types/surveys/types";
import { deleteResponsesAndDisplaysForSurvey, getQuotasSummary } from "./survey";
-// Mock prisma
-vi.mock("@formbricks/database", () => ({
- prisma: {
- response: {
- deleteMany: vi.fn(),
- },
- display: {
- deleteMany: vi.fn(),
- },
- $transaction: vi.fn(),
- surveyQuota: {
- findMany: vi.fn(),
- },
- },
-}));
-
-const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
+/**
+ * The fixtures declare only the three fields the service reads, so the hand-off to `getSurvey` (typed
+ * `TSurvey | null`) is cast once here. The fixture fields themselves stay typed against
+ * `@formbricks/types`, so a wrong block or element shape still fails typecheck.
+ */
+const mockSurvey = (survey: SurveyFileUploadFields | null) => {
+ getSurvey.mockResolvedValue(survey as TSurvey | null);
+};
+
+const mockResponsePages = (...pages: ScannedResponse[][]) => {
+ const findMany = vi.mocked(prisma.response.findMany);
+ findMany.mockReset();
+ for (const page of pages) {
+ findMany.mockResolvedValueOnce(page as never);
+ }
+ // Anything past the configured pages reads as "no more rows".
+ findMany.mockResolvedValue([] as never);
+};
beforeEach(() => {
- vi.resetModules();
- vi.resetAllMocks();
+ // Default: a survey with no file-upload element, so the response scan is skipped.
+ mockSurvey(surveyWithoutFileUpload);
+ deleteResponseFileUrls.mockReset();
+ deleteResponseFileUrls.mockResolvedValue(undefined);
});
describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
describe("Happy Path", () => {
test("Deletes all responses and displays for a survey", async () => {
- const { prisma } = await import("@formbricks/database");
-
// Mock $transaction to return the results directly
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 5 }, { count: 3 }]);
@@ -46,8 +63,6 @@ describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
});
test("Handles case with no responses or displays to delete", async () => {
- const { prisma } = await import("@formbricks/database");
-
// Mock $transaction to return zero counts
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 0 }, { count: 0 }]);
@@ -58,31 +73,190 @@ describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
deletedDisplaysCount: 0,
});
});
+
+ test("Deletes the uploaded files held by the deleted responses", async () => {
+ mockSurvey(surveyWithFileUpload);
+ mockResponsePages([
+ {
+ id: "response-1",
+ createdAt: scanTimestamp(0),
+ data: {
+ [fileUploadElement.id]: [storageUrl("file1.png"), storageUrl("file2.pdf")],
+ "other-element": "not a file",
+ },
+ },
+ responseWithFiles("response-2", ["file3.png"]),
+ ]);
+ vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 2 }, { count: 0 }]);
+
+ await deleteResponsesAndDisplaysForSurvey(surveyId);
+
+ expect(deleteResponseFileUrls).toHaveBeenCalledTimes(1);
+ expect(deleteResponseFileUrls).toHaveBeenCalledWith(
+ [storageUrl("file1.png"), storageUrl("file2.pdf"), storageUrl("file3.png")],
+ workspaceId
+ );
+ });
+
+ test("Reads the file-upload answers before the responses are deleted", async () => {
+ const callOrder: string[] = [];
+
+ mockSurvey(surveyWithFileUpload);
+ vi.mocked(prisma.response.findMany).mockReset();
+ vi.mocked(prisma.response.findMany).mockImplementation((() => {
+ callOrder.push("scan");
+ return Promise.resolve([responseWithFiles("response-1", ["f.png"])]) as never;
+ }) as never);
+ vi.mocked(prisma.$transaction).mockImplementation((() => {
+ callOrder.push("delete");
+ return Promise.resolve([{ count: 1 }, { count: 0 }]) as never;
+ }) as never);
+ deleteResponseFileUrls.mockImplementation(async () => {
+ callOrder.push("storage");
+ });
+
+ await deleteResponsesAndDisplaysForSurvey(surveyId);
+
+ // The scan must precede the row delete (the URLs live in response.data), and storage cleanup must
+ // follow it so files are never removed while their responses survive.
+ expect(callOrder).toEqual(["scan", "delete", "storage"]);
+ });
+
+ test("Collects files from every page when responses span the scan page size", async () => {
+ // A full first page (500) forces a second cursor-based query; the file on the later page must
+ // still reach storage cleanup.
+ const firstPage = Array.from({ length: 500 }, (_, index) =>
+ responseWithFiles(`response-${index}`, [`page1-${index}.png`], index)
+ );
+ const secondPage = [responseWithFiles("response-500", ["page2.png"], 500)];
+
+ mockSurvey(surveyWithFileUpload);
+ mockResponsePages(firstPage, secondPage);
+ vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 501 }, { count: 0 }]);
+
+ await deleteResponsesAndDisplaysForSurvey(surveyId);
+
+ // Third call returns [] and ends the loop: 500 == page size, then 1 < page size would stop it,
+ // so exactly two queries are expected here.
+ expect(prisma.response.findMany).toHaveBeenCalledTimes(2);
+
+ // The second query pages past the last row of the first page with a (createdAt, id) keyset, and
+ // orders by createdAt so it can use the existing (surveyId, createdAt) index.
+ const secondQuery = vi.mocked(prisma.response.findMany).mock.calls[1][0];
+ expect(secondQuery).toMatchObject({
+ orderBy: [{ createdAt: "asc" }, { id: "asc" }],
+ where: {
+ surveyId,
+ OR: [
+ { createdAt: { gt: scanTimestamp(499) } },
+ { createdAt: scanTimestamp(499), id: { gt: "response-499" } },
+ ],
+ },
+ });
+ // Keyset paging replaces cursor/skip entirely — a leftover offset would double-read rows.
+ expect(secondQuery).not.toHaveProperty("skip");
+ expect(secondQuery).not.toHaveProperty("cursor");
+
+ const deletedUrls = deleteResponseFileUrls.mock.calls.flatMap(([urls]) => urls);
+ expect(deletedUrls).toHaveLength(501);
+ expect(deletedUrls).toContain(storageUrl("page1-0.png"));
+ expect(deletedUrls).toContain(storageUrl("page2.png"));
+ });
+
+ test("Issues storage deletes in bounded chunks", async () => {
+ const responses = Array.from({ length: 250 }, (_, index) =>
+ responseWithFiles(`response-${index}`, [`file-${index}.png`])
+ );
+
+ mockSurvey(surveyWithFileUpload);
+ mockResponsePages(responses);
+ vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 250 }, { count: 0 }]);
+
+ await deleteResponsesAndDisplaysForSurvey(surveyId);
+
+ // 250 URLs at a chunk size of 100 => 100 + 100 + 50, so no single storage fan-out exceeds 100.
+ expect(deleteResponseFileUrls.mock.calls.map(([urls]) => urls.length)).toEqual([100, 100, 50]);
+ });
+
+ test("Skips the response scan when the survey has no file-upload element", async () => {
+ vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 3 }, { count: 1 }]);
+
+ await deleteResponsesAndDisplaysForSurvey(surveyId);
+
+ expect(prisma.response.findMany).not.toHaveBeenCalled();
+ expect(deleteResponseFileUrls).not.toHaveBeenCalled();
+ });
+
+ test("Skips storage cleanup when the survey no longer exists", async () => {
+ mockSurvey(null);
+ vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 0 }, { count: 0 }]);
+
+ await deleteResponsesAndDisplaysForSurvey(surveyId);
+
+ expect(prisma.response.findMany).not.toHaveBeenCalled();
+ expect(deleteResponseFileUrls).not.toHaveBeenCalled();
+ });
+
+ test("Ignores non-array answers stored under a file-upload element id", async () => {
+ mockSurvey(surveyWithFileUpload);
+ mockResponsePages([
+ { id: "response-1", createdAt: scanTimestamp(0), data: { [fileUploadElement.id]: "not-an-array" } },
+ // Numbers and nulls inside the array are dropped rather than cast to a delete target.
+ {
+ id: "response-2",
+ createdAt: scanTimestamp(1),
+ data: { [fileUploadElement.id]: [42, null] as unknown as string[] },
+ },
+ ]);
+ vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 2 }, { count: 0 }]);
+
+ await deleteResponsesAndDisplaysForSurvey(surveyId);
+
+ expect(deleteResponseFileUrls).not.toHaveBeenCalled();
+ });
});
describe("Sad Path", () => {
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
- const { prisma } = await import("@formbricks/database");
-
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
+ mockSurvey(surveyWithFileUpload);
+ mockResponsePages([responseWithFiles("response-1", ["file1.png"])]);
vi.mocked(prisma.$transaction).mockRejectedValue(errToThrow);
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(DatabaseError);
+
+ // The converse of the ordering guarantee: if the rows survive, their files must survive too.
+ expect(deleteResponseFileUrls).not.toHaveBeenCalled();
});
test("Throws a generic Error for other exceptions", async () => {
- const { prisma } = await import("@formbricks/database");
-
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.$transaction).mockRejectedValue(new Error(mockErrorMessage));
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(Error);
});
+
+ test("Reports the reset as successful when storage cleanup fails", async () => {
+ mockSurvey(surveyWithFileUpload);
+ mockResponsePages([responseWithFiles("response-1", ["file1.png"])]);
+ vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 1 }, { count: 0 }]);
+ deleteResponseFileUrls.mockRejectedValue(new Error("storage down"));
+ const loggerSpy = vi.spyOn(logger, "error").mockImplementation(() => undefined);
+
+ // The rows are already committed as deleted, so a storage failure must not surface as a failed
+ // reset the caller would retry — it is logged and the counts still come back.
+ const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
+
+ expect(result).toEqual({ deletedResponsesCount: 1, deletedDisplaysCount: 0 });
+ expect(loggerSpy).toHaveBeenCalled();
+
+ loggerSpy.mockRestore();
+ });
});
});
@@ -98,7 +272,6 @@ describe("Tests for getQuotasSummary service", () => {
},
} as unknown as Awaited>[number],
]);
-
const result = await getQuotasSummary(surveyId);
expect(result).toEqual([
{
@@ -110,7 +283,6 @@ describe("Tests for getQuotasSummary service", () => {
},
]);
});
-
test("Returns 0 percentage if limit is 0", async () => {
vi.mocked(prisma.surveyQuota.findMany).mockResolvedValue([
{
@@ -122,7 +294,6 @@ describe("Tests for getQuotasSummary service", () => {
},
} as unknown as Awaited>[number],
]);
-
const result = await getQuotasSummary(surveyId);
expect(result).toEqual([
{
@@ -134,25 +305,17 @@ describe("Tests for getQuotasSummary service", () => {
},
]);
});
-
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
- const { prisma } = await import("@formbricks/database");
-
vi.mocked(prisma.surveyQuota.findMany).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Database error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
})
);
-
await expect(getQuotasSummary(surveyId)).rejects.toThrow(DatabaseError);
});
-
test("Throws a generic Error for other exceptions", async () => {
- const { prisma } = await import("@formbricks/database");
-
vi.mocked(prisma.surveyQuota.findMany).mockRejectedValue(new Error("Database error"));
-
await expect(getQuotasSummary(surveyId)).rejects.toThrow(Error);
});
});
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/survey.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/survey.ts
index 0d60ca1f9829..823eb6e729fb 100644
--- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/survey.ts
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/survey.ts
@@ -1,13 +1,149 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { Prisma } from "@formbricks/database/prisma";
+import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { convertFloatTo2Decimal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
+import { getSurvey } from "@/lib/survey/service";
+import { deleteResponseFileUrls } from "@/modules/storage/lib/delete-response-files";
+import { getSurveyFileUploadConfigs } from "@/modules/storage/utils";
+
+/**
+ * Responses are scanned in pages so resetting a survey with a large response count never holds every
+ * `response.data` blob at once. Note this bounds only the blobs: the collected URLs still accumulate
+ * across pages, which is what STORAGE_DELETE_CHUNK_SIZE bounds on the way out.
+ */
+const RESPONSE_FILE_SCAN_PAGE_SIZE = 500;
+
+/**
+ * Storage deletes are issued in bounded chunks. `deleteResponseFileUrls` fans out with `Promise.all`
+ * over every URL it is handed, so passing a whole survey's worth at once would open one storage
+ * request per uploaded file. Chunking caps the in-flight requests no matter how many files the scan
+ * collected.
+ */
+const STORAGE_DELETE_CHUNK_SIZE = 100;
+
+/** One response row as the scan below selects it. */
+type ScannedResponseRow = { id: string; createdAt: Date; data: Prisma.JsonValue };
+
+/** Keyset position in the scan: the last row read, ordered by (createdAt, id). */
+type ResponseScanCursor = { createdAt: Date; id: string };
+
+/**
+ * Keyset predicate for "strictly after this row" in (createdAt, id) order.
+ *
+ * The scan orders by createdAt rather than id alone so it can ride the existing
+ * `@@index([surveyId, createdAt])` on Response. Ordering by `id` would have no supporting index —
+ * `(surveyId, id)` does not exist — leaving the planner to either sort the survey's whole response set
+ * on every page or scan by primary key across the entire table. `id` is only the tiebreaker that makes
+ * the order total, so responses sharing a createdAt are neither skipped nor read twice.
+ */
+const afterCursor = (cursor: ResponseScanCursor) => ({
+ OR: [{ createdAt: { gt: cursor.createdAt } }, { createdAt: cursor.createdAt, id: { gt: cursor.id } }],
+});
+
+/**
+ * Pulls the storage URLs out of one page of scanned responses.
+ *
+ * Only file-upload answers hold storage URLs, and they are always stored as an array of strings.
+ * Anything else under the same key is skipped rather than cast, so malformed data cannot produce a
+ * bogus delete target.
+ */
+const collectFileUrlsFromPage = (
+ responses: ScannedResponseRow[],
+ fileUploadElementIds: Set
+): string[] => {
+ const fileUrls: string[] = [];
+
+ for (const response of responses) {
+ for (const [elementId, answer] of Object.entries(response.data ?? {})) {
+ if (fileUploadElementIds.has(elementId) && Array.isArray(answer)) {
+ fileUrls.push(...answer.filter((url): url is string => typeof url === "string"));
+ }
+ }
+ }
+
+ return fileUrls;
+};
+
+/**
+ * Collects the storage URLs a survey's file-upload answers point at, so they can be deleted once the
+ * responses themselves are gone.
+ *
+ * Must run *before* the responses are deleted: the URLs only exist inside `response.data`, so once the
+ * rows are gone there is nothing left to tell storage which objects are now unreferenced.
+ *
+ * Mirrors the single-response delete path (`findAndDeleteUploadedFilesInResponse` in
+ * lib/response/service.ts): the id set comes from the union of `blocks` and `questions` via
+ * `getSurveyFileUploadConfigs`, because a survey holds file uploads in either shape and keying off one
+ * of them silently skips the other.
+ */
+const collectSurveyResponseFileUrls = async (
+ surveyId: string
+): Promise<{ fileUrls: string[]; workspaceId: string | undefined }> => {
+ // getSurvey is reactCache'd and the reset action fetches the same survey immediately before calling
+ // this, so it resolves from the request cache rather than issuing a second round-trip — and it hands
+ // back typed blocks/questions instead of raw JSON columns needing a cast. This is also the source the
+ // single-response cleanup path reads the survey from.
+ const survey = await getSurvey(surveyId);
+
+ if (!survey) {
+ return { fileUrls: [], workspaceId: undefined };
+ }
+
+ const fileUploadElementIds = new Set(
+ getSurveyFileUploadConfigs({ blocks: survey.blocks, questions: survey.questions }).map(
+ (config) => config.id
+ )
+ );
+
+ // No file-upload element in the survey's *current* definition, so there is no key this scan would
+ // match — skip it. Note this is about today's blocks/questions, not the response history: answers
+ // left by an upload element that was since deleted sit under an id no longer in the set, and are not
+ // cleaned up here or by the single-response path. Widening the match to "any answer shaped like a
+ // storage URL" is deliberately not the fix — see the PR's Open gaps for why that would let one
+ // survey's reset delete another's live files.
+ if (fileUploadElementIds.size === 0) {
+ return { fileUrls: [], workspaceId: survey.workspaceId };
+ }
+
+ const fileUrls: string[] = [];
+ let cursor: ResponseScanCursor | undefined;
+
+ for (;;) {
+ const responses = await prisma.response.findMany({
+ where: { surveyId, ...(cursor ? afterCursor(cursor) : {}) },
+ select: { id: true, createdAt: true, data: true },
+ orderBy: [{ createdAt: "asc" }, { id: "asc" }],
+ take: RESPONSE_FILE_SCAN_PAGE_SIZE,
+ });
+
+ if (responses.length === 0) {
+ break;
+ }
+
+ fileUrls.push(...collectFileUrlsFromPage(responses, fileUploadElementIds));
+
+ // A short page means the last one. The `lastRow` check only guards the cursor from going undefined
+ // and re-reading the same page forever; a full page always has a last row.
+ const lastRow = responses.at(-1);
+ if (responses.length < RESPONSE_FILE_SCAN_PAGE_SIZE || !lastRow) {
+ break;
+ }
+
+ cursor = { createdAt: lastRow.createdAt, id: lastRow.id };
+ }
+
+ return { fileUrls, workspaceId: survey.workspaceId };
+};
export const deleteResponsesAndDisplaysForSurvey = async (
surveyId: string
): Promise<{ deletedResponsesCount: number; deletedDisplaysCount: number }> => {
try {
+ // Read the file-upload answers while the responses still exist (see collectSurveyResponseFileUrls).
+ const { fileUrls, workspaceId } = await collectSurveyResponseFileUrls(surveyId);
+
// Delete all responses for this survey
const [deletedResponsesCount, deletedDisplaysCount] = await prisma.$transaction([
@@ -23,6 +159,25 @@ export const deleteResponsesAndDisplaysForSurvey = async (
}),
]);
+ // Runs after the rows are gone so a storage failure can never delete files whose responses
+ // survived, and chunked so the number of concurrent storage requests stays bounded.
+ //
+ // The responses are already committed as deleted at this point, so cleanup must not turn a
+ // successful reset into a failed one: deleteResponseFileUrls already logs and swallows per-file
+ // errors, and this guard covers an unexpected throw. The cost of failing here is objects left in
+ // storage — the pre-existing behaviour — not a reset the caller has to retry.
+ for (let i = 0; i < fileUrls.length; i += STORAGE_DELETE_CHUNK_SIZE) {
+ const chunk = fileUrls.slice(i, i + STORAGE_DELETE_CHUNK_SIZE);
+ try {
+ await deleteResponseFileUrls(chunk, workspaceId);
+ } catch (error) {
+ logger.error(
+ { error, surveyId, workspaceId, fileCount: chunk.length },
+ "Failed to delete response files after resetting a survey"
+ );
+ }
+ }
+
return {
deletedResponsesCount: deletedResponsesCount.count,
deletedDisplaysCount: deletedDisplaysCount.count,
diff --git a/apps/web/app/api/auth/[...all]/route.test.ts b/apps/web/app/api/auth/[...all]/route.test.ts
index be90526c695c..424e97798563 100644
--- a/apps/web/app/api/auth/[...all]/route.test.ts
+++ b/apps/web/app/api/auth/[...all]/route.test.ts
@@ -12,7 +12,9 @@ import { GET, POST } from "./route";
// assert the test's own arrangement instead. `api` mirrors the shape route.ts reads the label
// vocabulary from — each endpoint function carries its declared path.
const { handlerMock, runWithCtxMock } = vi.hoisted(() => ({
- handlerMock: vi.fn(async () => new Response("ok", { status: 200 })),
+ // Parameter declared even though the body ignores it: `mock.calls` is typed from the signature, so
+ // without it `calls[0]` is a zero-length tuple and every `calls[0][0]` read below is a type error.
+ handlerMock: vi.fn(async (_request: Request) => new Response("ok", { status: 200 })),
runWithCtxMock: vi.fn((fn: () => unknown) => fn()),
}));
@@ -24,6 +26,10 @@ vi.mock("@/modules/auth/lib/auth", () => ({
signInEmail: { path: "/sign-in/email" },
resetPassword: { path: "/reset-password" },
resetPasswordCallback: { path: "/reset-password/:token" },
+ // The OAuth callback Better Auth really declares (`api/routes/callback.mjs`). Present here so the
+ // label vocabulary matches production: without it `callback` is not a known first segment and a
+ // pinned SSO callback would label `unknown` in this suite while labelling correctly in the app.
+ callbackOAuth: { path: "/callback/:id" },
// A nullish entry, deliberately. The label vocabulary is built at MODULE LOAD from this object,
// so `endpoint.path` on a null would throw there and take down every `/api/auth/*` request — the
// route, not just the tag. Importing this file at all is what asserts it does not.
@@ -35,6 +41,10 @@ vi.mock("@/modules/ee/sso/lib/sso-request-context", () => ({
runWithSsoRequestContext: runWithCtxMock,
}));
+// NOTE on assertions below: a Request must never be asserted with `toHaveBeenCalledWith`. Request state
+// lives in internal slots, so it has no own properties and ANY two Request objects compare deep-equal
+// under vitest — such an assertion passes even when the handler was called with a completely different
+// URL. Assert identity (`toBe`) for pass-through, and read `.url` off the recorded call for a rewrite.
describe("[...all] Better Auth route (ENG-1054 cutover)", () => {
beforeEach(() => {
handlerMock.mockClear();
@@ -59,7 +69,7 @@ describe("[...all] Better Auth route (ENG-1054 cutover)", () => {
const response = await GET(request);
expect(response.status).toBe(200);
expect(runWithCtxMock).toHaveBeenCalledTimes(1);
- expect(handlerMock).toHaveBeenCalledWith(request);
+ expect(handlerMock.mock.calls[0][0]).toBe(request);
expect(calls).toEqual(["wrapper:start", "handler", "wrapper:end"]);
});
@@ -68,7 +78,7 @@ describe("[...all] Better Auth route (ENG-1054 cutover)", () => {
const response = await POST(request);
expect(response.status).toBe(200);
expect(runWithCtxMock).toHaveBeenCalledTimes(1);
- expect(handlerMock).toHaveBeenCalledWith(request);
+ expect(handlerMock.mock.calls[0][0]).toBe(request);
});
test("GET and POST share the single wrapped handler", () => {
@@ -114,4 +124,71 @@ describe("[...all] Better Auth route — observability context (ENG-2259)", () =
expect(seen).toEqual({ path: "/reset-password/*", method: "POST" });
expect(JSON.stringify(seen)).not.toContain(token);
});
+
+ // The label is derived from the MAPPED request, so a pinned SSO callback reports the endpoint that
+ // actually ran. Labelling the raw URL would file it under `/oauth2/*` — the MCP OAuth
+ // authorization-server facet — which is the one bucket it must never be confused with (ENG-2343).
+ test("labels a pinned SSO callback as the endpoint that ran, not as MCP OAuth", async () => {
+ const seen = await captureContextDuringHandler(
+ new Request("http://localhost/api/auth/oauth2/callback/openid?code=abc&state=xyz", {
+ method: "POST",
+ })
+ );
+
+ expect(seen).toEqual({ path: "/callback/*", method: "POST" });
+ });
+});
+
+/**
+ * The pinned SSO callback URL (ENG-2343). `redirectURI` makes Better Auth advertise
+ * `/api/auth/oauth2/callback/{providerId}` — the URL customer IdPs have had registered since v5.2 — but no
+ * 1.7 route is mounted there, so this route is what serves it. The mapper itself is covered exhaustively in
+ * legacy-sso-callback.test.ts; what needs proving *here* is that the route actually applies it, because the
+ * two delegation tests above pass either way: the mapper returns the identical request object on every
+ * non-pinned path, so they would still be green with the call deleted.
+ */
+describe("[...all] Better Auth route — pinned SSO callback (ENG-2343)", () => {
+ beforeEach(() => {
+ handlerMock.mockClear();
+ runWithCtxMock.mockClear();
+ });
+
+ test("hands Better Auth the current callback path, preserving code and state", async () => {
+ await GET(new Request("http://localhost/api/auth/oauth2/callback/openid?code=abc&state=xyz"));
+
+ expect(handlerMock).toHaveBeenCalledTimes(1);
+ const handled = handlerMock.mock.calls[0][0];
+ expect(handled.url).toBe("http://localhost/api/auth/callback/openid?code=abc&state=xyz");
+ });
+
+ test("still maps inside the SSO request context", async () => {
+ const calls: string[] = [];
+ runWithCtxMock.mockImplementationOnce(async (fn: () => unknown) => {
+ calls.push("wrapper:start");
+ const response = await fn();
+ calls.push("wrapper:end");
+ return response;
+ });
+ handlerMock.mockImplementationOnce(async () => {
+ calls.push("handler");
+ return new Response("ok", { status: 200 });
+ });
+
+ await GET(new Request("http://localhost/api/auth/oauth2/callback/saml?code=abc"));
+
+ expect(calls).toEqual(["wrapper:start", "handler", "wrapper:end"]);
+ // Without this the test is a duplicate of the ordering test above: it would stay green with the
+ // mapper call deleted, since ordering does not depend on it.
+ expect(handlerMock.mock.calls[0][0].url).toBe("http://localhost/api/auth/callback/saml?code=abc");
+ });
+
+ // The sibling routes of our own MCP OAuth authorization server must pass through untouched — the same
+ // object, not a rebuilt equivalent.
+ test("leaves a sibling MCP OAuth route untouched", async () => {
+ const request = new Request("http://localhost/api/auth/oauth2/userinfo");
+
+ await GET(request);
+
+ expect(handlerMock.mock.calls[0][0]).toBe(request);
+ });
});
diff --git a/apps/web/app/api/auth/[...all]/route.ts b/apps/web/app/api/auth/[...all]/route.ts
index dc30d561c3a1..bfb6f3a8b32f 100644
--- a/apps/web/app/api/auth/[...all]/route.ts
+++ b/apps/web/app/api/auth/[...all]/route.ts
@@ -1,6 +1,8 @@
import { auth } from "@/modules/auth/lib/auth";
import { createAuthPathLabeller } from "@/modules/auth/lib/better-auth-path-label";
import { runWithBetterAuthRequestContext } from "@/modules/auth/lib/better-auth-request-context";
+import { mapLegacySsoCallbackRequest } from "@/modules/auth/lib/legacy-sso-callback";
+import { normalizeDcrRequest } from "@/modules/auth/lib/mcp-dcr-application-type";
import { runWithSsoRequestContext } from "@/modules/ee/sso/lib/sso-request-context";
// Force-no-store so Better Auth's outbound SSO fetches (token exchange, userinfo, JWKS) are never
@@ -25,6 +27,13 @@ const labelAuthPath = createAuthPathLabeller(Object.values(auth.api).map((endpoi
* two cannot coexist: both own `/api/auth/*`). More specific `/api/auth/*` routes (the SAML bridge,
* SSO-recovery completion) still take precedence over this catch-all.
*
+ * It also serves the pinned SSO callback path via `mapLegacySsoCallbackRequest` (ENG-2343), which is
+ * why no separate `/api/auth/oauth2/callback/[providerId]` route exists: nothing else claims that path,
+ * so the catch-all already receives it. The mapping runs FIRST, and everything below reads the mapped
+ * request — the label especially. `/api/auth/oauth2/callback/{providerId}` is not a path Better Auth
+ * declares, so labelling the raw URL would bucket an SSO callback under `/oauth2/*`, which is the MCP
+ * OAuth authorization-server facet: the one place a reader must not confuse it with.
+ *
* `auth.handler` is wrapped in `runWithSsoRequestContext` so the SSO database hooks can carry state
* across the request via AsyncLocalStorage — the provisioning decision (`user.create.before` →
* `user.create.after`) and the pending identity (`mapProfileToUser` → the collision-recovery
@@ -44,9 +53,18 @@ const labelAuthPath = createAuthPathLabeller(Object.values(auth.api).map((endpoi
* calls it and `return`s (`better-auth/dist/api/index.mjs:194-197`), skipping the logger path
* entirely — wiring it would silence the very capture that surfaces genuine internal faults.
*/
-const handler = (request: Request): Promise =>
- runWithBetterAuthRequestContext({ path: labelAuthPath(request.url), method: request.method }, () =>
- runWithSsoRequestContext(() => auth.handler(request))
+const handler = async (request: Request): Promise => {
+ // Before anything else reads the path: this catch-all serves the pinned v5.2 SSO callback URL, which no
+ // Better Auth version mounts a handler on any more. Everything downstream — the endpoint label, the SSO
+ // hooks, the audits — reads the MAPPED request, so each sees the endpoint that actually ran.
+ // Two normalisations, both because 1.7 changed a contract that clients and IdPs already depend on and
+ // neither is ours to change: the pinned SSO callback path, and `application_type` on dynamic client
+ // registration (see each module). Both no-op for every other request.
+ const mappedRequest = await normalizeDcrRequest(mapLegacySsoCallbackRequest(request));
+ return runWithBetterAuthRequestContext(
+ { path: labelAuthPath(mappedRequest.url), method: mappedRequest.method },
+ () => runWithSsoRequestContext(() => auth.handler(mappedRequest))
);
+};
export { handler as GET, handler as POST };
diff --git a/apps/web/app/api/mcp/route.test.ts b/apps/web/app/api/mcp/route.test.ts
index 7125ed642a73..23b8bcd19631 100644
--- a/apps/web/app/api/mcp/route.test.ts
+++ b/apps/web/app/api/mcp/route.test.ts
@@ -18,15 +18,15 @@ import { authenticateApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { POST } from "./route";
-const { verifyAccessTokenMock, userFindUniqueMock } = vi.hoisted(() => ({
- verifyAccessTokenMock: vi.fn(),
+const { verifyBearerTokenMock, userFindUniqueMock } = vi.hoisted(() => ({
+ verifyBearerTokenMock: vi.fn(),
userFindUniqueMock: vi.fn(),
}));
vi.mock("@better-auth/oauth-provider/resource-client", () => ({
oauthProviderResourceClient: vi.fn(() => ({
getActions: () => ({
- verifyAccessToken: verifyAccessTokenMock,
+ verifyBearerToken: verifyBearerTokenMock,
}),
})),
}));
@@ -147,7 +147,7 @@ describe("POST /api/mcp", () => {
vi.mocked(applyRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
userFindUniqueMock.mockResolvedValue({ isActive: true });
- verifyAccessTokenMock.mockResolvedValue({
+ verifyBearerTokenMock.mockResolvedValue({
aud: MCP_AUDIENCE,
sub: "user_1",
email: "person@example.com",
@@ -415,7 +415,7 @@ describe("POST /api/mcp", () => {
expect(response.status).toBe(200);
await readMcpResponse(response);
expect(authenticateApiKeyFromHeaders).toHaveBeenCalledTimes(1);
- expect(verifyAccessTokenMock).not.toHaveBeenCalled();
+ expect(verifyBearerTokenMock).not.toHaveBeenCalled();
expect(listV3Surveys).toHaveBeenCalledWith(
expect.objectContaining({
authentication: apiKeyAuth,
@@ -450,7 +450,7 @@ describe("POST /api/mcp", () => {
expect(response.status).toBe(200);
expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled();
- expect(verifyAccessTokenMock).toHaveBeenCalledWith(
+ expect(verifyBearerTokenMock).toHaveBeenCalledWith(
"eyJhbGciOiJFZERTQSJ9.payload.signature",
expect.objectContaining({
verifyOptions: expect.objectContaining({
@@ -478,7 +478,7 @@ describe("POST /api/mcp", () => {
});
test("rejects invalid OAuth bearer tokens with an OAuth challenge", async () => {
- verifyAccessTokenMock.mockRejectedValueOnce(new Error("invalid token"));
+ verifyBearerTokenMock.mockRejectedValueOnce(new Error("invalid token"));
const response = await POST(
createMcpRequest(
@@ -503,7 +503,7 @@ describe("POST /api/mcp", () => {
});
test("blocks write tools for read-only OAuth tokens", async () => {
- verifyAccessTokenMock.mockResolvedValueOnce({
+ verifyBearerTokenMock.mockResolvedValueOnce({
aud: MCP_AUDIENCE,
sub: "user_1",
email: "person@example.com",
@@ -549,7 +549,7 @@ describe("POST /api/mcp", () => {
test("blocks workflow write tools for tokens without workflows:write", async () => {
// A write-capable user whose OAuth token was only granted read scopes (surveys:read + workflows:read)
// must not be able to reach a workflow mutation — the ENG-1967 token-scope boundary.
- verifyAccessTokenMock.mockResolvedValueOnce({
+ verifyBearerTokenMock.mockResolvedValueOnce({
aud: MCP_AUDIENCE,
sub: "user_1",
email: "person@example.com",
diff --git a/apps/web/app/api/v3/surveys/serializers.test.ts b/apps/web/app/api/v3/surveys/serializers.test.ts
index 2e8182408d6e..4fed743f75c1 100644
--- a/apps/web/app/api/v3/surveys/serializers.test.ts
+++ b/apps/web/app/api/v3/surveys/serializers.test.ts
@@ -676,6 +676,7 @@ describe("serializeV3SurveyListItem", () => {
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-16T10:00:00.000Z"),
responseCount: 0,
+ completedResponseCount: 0,
singleUse: null,
} satisfies Omit;
@@ -700,4 +701,18 @@ describe("serializeV3SurveyListItem", () => {
expect(serializeV3SurveyListItem(survey).creator).toBeNull();
});
+
+ test("exposes the total and the completed response counts", () => {
+ const survey = {
+ ...baseListSurvey,
+ responseCount: 7,
+ completedResponseCount: 4,
+ creator: null,
+ } satisfies TSurveyListRecord;
+
+ const serialized = serializeV3SurveyListItem(survey);
+
+ expect(serialized.responseCount).toBe(7);
+ expect(serialized.completedResponseCount).toBe(4);
+ });
});
diff --git a/apps/web/app/api/v3/surveys/serializers.ts b/apps/web/app/api/v3/surveys/serializers.ts
index 718a877fab12..7d38b34f380f 100644
--- a/apps/web/app/api/v3/surveys/serializers.ts
+++ b/apps/web/app/api/v3/surveys/serializers.ts
@@ -26,6 +26,7 @@ type TV3SurveyListItemBase = Pick<
| "createdAt"
| "updatedAt"
| "responseCount"
+ | "completedResponseCount"
>;
export type TV3SurveyListItem = TV3SurveyListItemBase & {
@@ -85,6 +86,7 @@ export function serializeV3SurveyListItem(survey: TSurveyListRecord): TV3SurveyL
createdAt: survey.createdAt,
updatedAt: survey.updatedAt,
responseCount: survey.responseCount,
+ completedResponseCount: survey.completedResponseCount,
creator: serializeV3SurveyCreator(survey.creator),
};
}
diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock
index 87a8df9fe439..aa9d997acd8f 100644
--- a/apps/web/i18n.lock
+++ b/apps/web/i18n.lock
@@ -1583,12 +1583,14 @@ checksums:
workspace/analysis/charts/already_on_dashboard: c2cee946860c71a71cf03392b2d1fc3a
workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df
workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
+ workspace/analysis/charts/bar_direction: d78ce3e340da83ec3904a31a4e01b5cb
workspace/analysis/charts/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
workspace/analysis/charts/chart_added_to_dashboard: 7bc429ab605cb89a9232c26be008cc00
workspace/analysis/charts/chart_data: 6739a9576b357a58d73ff0c9bf8db0e4
workspace/analysis/charts/chart_data_tab: b7b46ab6ce9606032c8f81f6f6afbb9b
workspace/analysis/charts/chart_deleted_successfully: 79148f471cd9acc2c8d0d033fb85437e
workspace/analysis/charts/chart_deletion_error: 267eb65c168e726075d7cea678dd32e0
+ workspace/analysis/charts/chart_display_settings: 00e38d777382e98fab9049f5f85f91de
workspace/analysis/charts/chart_duplicated_successfully: 755c4ce5bf533764d549a53c33e32165
workspace/analysis/charts/chart_duplication_error: 90d7166c85188b52f821c9d9f53ff8c4
workspace/analysis/charts/chart_name: cdb36e2f121a7b9c28298e15ab8218dc
@@ -1630,6 +1632,8 @@ checksums:
workspace/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147
workspace/analysis/charts/dimensions: f09d837ac25f58986a769bd48ea15022
workspace/analysis/charts/dimensions_toggle_description: 31eb28f3c83c04bbe37799758ca9f595
+ workspace/analysis/charts/distribution_segment_label: e75329715441ae0d1388a0aaa452b656
+ workspace/analysis/charts/distribution_value_share: 954274ec06ca0aeafb52b68ef8a406f6
workspace/analysis/charts/edit_chart_description: 822890e4b6068096e2fe8b7b78b4474f
workspace/analysis/charts/edit_chart_title: fd3e7f8c53280bfad8f4034c055f4c71
workspace/analysis/charts/edit_chart_title_named: 216b4226fb611b3694bc222026722845
@@ -1720,6 +1724,7 @@ checksums:
workspace/analysis/charts/group_by: 3f1cedea7783018ce83f2fab0051a738
workspace/analysis/charts/group_by_description: bebcfe28bb315834aa3a307834acfbe0
workspace/analysis/charts/group_data: 55c0035773d8c6b7f4d96363a61cda82
+ workspace/analysis/charts/horizontal_bars: 9f7e34591bab8b54fb713078677cf9d5
workspace/analysis/charts/is_not_set: 906801489132487ef457652af4835142
workspace/analysis/charts/is_set: 9850468156356f95884bbaf56b6687aa
workspace/analysis/charts/language_value_unspecified: 85dba018c7aea3e0590a113361f419c5
@@ -1749,6 +1754,9 @@ checksums:
workspace/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7
workspace/analysis/charts/or_filter_logic: 0208d355f231c386b19390f0bea41b95
workspace/analysis/charts/original: 7e55782bdf7cb49f5616b326c003c278
+ workspace/analysis/charts/pie_display: eb53ad78765ddc4050d898a70f2fac68
+ workspace/analysis/charts/pie_display_breakdown: 5a072a1240ff9b5d8246af085ef92192
+ workspace/analysis/charts/pie_display_pie: 077915149770d6fb8bc73d743db4e901
workspace/analysis/charts/please_enter_chart_name: 9258b71b2cb09d22ffe33de1755e7309
workspace/analysis/charts/please_select_dashboard: 8f062db96f815ed8268584dd8d292fa6
workspace/analysis/charts/predefined_measures: 7651141f62c991954edcff70899b2a8b
@@ -1777,6 +1785,7 @@ checksums:
workspace/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84
workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
+ workspace/analysis/charts/vertical_bars: 408174fe449ea6f5457988dd218183be
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
workspace/analysis/dashboards/chart_duplicate_failed: 90d7166c85188b52f821c9d9f53ff8c4
workspace/analysis/dashboards/chart_duplicated: 52765f173bd6fd5382731f8e06e436e0
@@ -2878,6 +2887,7 @@ checksums:
workspace/surveys/archive_survey_warning: 02f30c8901f34d627bdab15e0f68e71a
workspace/surveys/archiving_survey: 08e4cc73698d79dc98c70df6849c7790
workspace/surveys/change_status: abe7acd9be0d3e77c087a5459ef41059
+ workspace/surveys/completed_responses: 0e4bbce9985f25eb673d9a054c8d5334
workspace/surveys/copy_survey: de8142b45e7bca61f2dca0069a62b417
workspace/surveys/copy_survey_description: 5d86af9371f45852b4d1afea4978166e
workspace/surveys/copy_survey_error: 74cab7d84ea8b669e106d4c326cac005
diff --git a/apps/web/integration/credential-backfill.integration.test.ts b/apps/web/integration/credential-backfill.integration.test.ts
index 1b48bb7778a8..e7370755ce0a 100644
--- a/apps/web/integration/credential-backfill.integration.test.ts
+++ b/apps/web/integration/credential-backfill.integration.test.ts
@@ -1,3 +1,4 @@
+import { createLocalAccountIssuer } from "@better-auth/core/db";
import { beforeEach, describe, expect, test } from "vitest";
import { prisma } from "@formbricks/database";
import { resetDb } from "@/integration/reset-db";
@@ -6,6 +7,20 @@ import { auth } from "@/modules/auth/lib/auth";
// The cutover data migration under test (auto-discovered by the migration runner at the flip).
import { backfillCredentialAccounts } from "../../../packages/database/migration/20260619120000_eng_1054_credential_account_backfill/migration";
+/**
+ * This migration predates `Account.issuer` (ENG-2343) and, by the runner's own interleaving guarantee
+ * (data and schema migrations run in strict timestamp order), always runs BEFORE the schema migration
+ * that adds that column — so it genuinely cannot set it, and its rows are inserted with issuer=NULL.
+ * In real deployments that's fine: ENG-2343's schema migration runs immediately after this one and
+ * backfills every NULL-issuer credential row. A test calling this function standalone has to simulate
+ * that follow-up step itself before asserting a real Better Auth sign-in succeeds.
+ */
+const applyEng2343IssuerBackfill = (): Promise<{ count: number }> =>
+ prisma.account.updateMany({
+ where: { provider: "credential", issuer: null },
+ data: { issuer: createLocalAccountIssuer("credential") },
+ });
+
/**
* Integration coverage for the cutover credential-account backfill (ENG-1054) against real Postgres.
* Proves the scariest cutover guarantee: an existing NextAuth-era user (bcrypt hash on User.password,
@@ -39,6 +54,8 @@ describe("Credential-account backfill (real Postgres)", () => {
expect(account?.userId).toBe(user.id);
expect(account?.password).toBe(user.password);
+ await applyEng2343IssuerBackfill();
+
// and BA email/password sign-in works with the ORIGINAL password
const res = await auth.api.signInEmail({
body: { email: "legacy@example.com", password },
@@ -124,6 +141,8 @@ describe("Credential-account backfill (real Postgres)", () => {
const stats = await backfillCredentialAccounts(prisma);
expect(stats.inserted).toBe(1);
+ await applyEng2343IssuerBackfill();
+
// both the SSO and the new credential account coexist, and password sign-in works
expect(await prisma.account.count({ where: { userId: user.id } })).toBe(2);
const res = await auth.api.signInEmail({
diff --git a/apps/web/lib/account/service.test.ts b/apps/web/lib/account/service.test.ts
deleted file mode 100644
index ab7ed9062df8..000000000000
--- a/apps/web/lib/account/service.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { beforeEach, describe, expect, test, vi } from "vitest";
-import { Prisma } from "@formbricks/database/prisma";
-import { upsertAccount } from "./service";
-
-const { mockUpsert } = vi.hoisted(() => ({
- mockUpsert: vi.fn(),
-}));
-
-vi.mock("@formbricks/database", () => ({
- prisma: {
- account: {
- upsert: mockUpsert,
- },
- },
-}));
-
-describe("account service", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- test("upsertAccount keeps user ownership immutable on update", async () => {
- const accountData = {
- userId: "user-1",
- type: "oauth",
- provider: "google",
- providerAccountId: "provider-1",
- access_token: "access-token",
- refresh_token: "refresh-token",
- expires_at: 123,
- scope: "openid email",
- token_type: "Bearer",
- id_token: "id-token",
- };
-
- mockUpsert.mockResolvedValue({
- id: "account-1",
- createdAt: new Date(),
- updatedAt: new Date(),
- ...accountData,
- });
-
- await upsertAccount(accountData);
-
- expect(mockUpsert).toHaveBeenCalledWith({
- where: {
- provider_providerAccountId: {
- provider: "google",
- providerAccountId: "provider-1",
- },
- },
- create: accountData,
- update: {
- access_token: "access-token",
- refresh_token: "refresh-token",
- expires_at: 123,
- scope: "openid email",
- token_type: "Bearer",
- id_token: "id-token",
- },
- });
- });
-
- test("upsertAccount wraps Prisma known request errors", async () => {
- const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), {
- message: "duplicate account",
- });
-
- mockUpsert.mockRejectedValue(prismaError);
-
- await expect(
- upsertAccount({
- userId: "user-1",
- type: "oauth",
- provider: "google",
- providerAccountId: "provider-1",
- })
- ).rejects.toMatchObject({
- name: "DatabaseError",
- message: "duplicate account",
- });
- });
-
- test("upsertAccount rethrows non-Prisma errors", async () => {
- const error = new Error("unexpected failure");
- mockUpsert.mockRejectedValue(error);
-
- await expect(
- upsertAccount({
- userId: "user-1",
- type: "oauth",
- provider: "google",
- providerAccountId: "provider-1",
- })
- ).rejects.toThrow("unexpected failure");
- });
-});
diff --git a/apps/web/lib/account/service.ts b/apps/web/lib/account/service.ts
deleted file mode 100644
index c8e070b80e26..000000000000
--- a/apps/web/lib/account/service.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { prisma } from "@formbricks/database";
-import { Prisma, PrismaClient } from "@formbricks/database/prisma";
-import { TAccount, TAccountInput, ZAccountInput } from "@formbricks/types/account";
-import { DatabaseError } from "@formbricks/types/errors";
-import { validateInputs } from "../utils/validate";
-
-type TAccountDbClient = PrismaClient | Prisma.TransactionClient;
-
-const getDbClient = (tx?: Prisma.TransactionClient): TAccountDbClient => tx ?? prisma;
-
-export const createAccount = async (accountData: TAccountInput): Promise => {
- validateInputs([accountData, ZAccountInput]);
-
- try {
- const account = await prisma.account.create({
- data: accountData,
- });
- return account;
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
-
- throw error;
- }
-};
-
-export const upsertAccount = async (
- accountData: TAccountInput,
- tx?: Prisma.TransactionClient
-): Promise => {
- const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
- const updateAccountData: Omit = {
- access_token: validatedAccountData.access_token,
- refresh_token: validatedAccountData.refresh_token,
- expires_at: validatedAccountData.expires_at,
- scope: validatedAccountData.scope,
- token_type: validatedAccountData.token_type,
- id_token: validatedAccountData.id_token,
- };
-
- try {
- const account = await getDbClient(tx).account.upsert({
- where: {
- provider_providerAccountId: {
- provider: validatedAccountData.provider,
- providerAccountId: validatedAccountData.providerAccountId,
- },
- },
- create: validatedAccountData,
- update: updateAccountData,
- });
-
- return account;
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- throw new DatabaseError(error.message);
- }
-
- throw error;
- }
-};
diff --git a/apps/web/lib/utils/client-ip.test.ts b/apps/web/lib/utils/client-ip.test.ts
index b2b4b6123d91..3dab8d914a8b 100644
--- a/apps/web/lib/utils/client-ip.test.ts
+++ b/apps/web/lib/utils/client-ip.test.ts
@@ -1,4 +1,5 @@
-import { getIp } from "@better-auth/core/utils/ip";
+// Renamed getIp -> getIP in Better Auth 1.7 (ENG-2343).
+import { getIP } from "@better-auth/core/utils/ip";
import * as nextHeaders from "next/headers";
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
@@ -159,7 +160,7 @@ describe("Better Auth IP configuration", () => {
ipAddressHeaders: [FORMBRICKS_CLIENT_IP_HEADER],
ipv6Subnet: 64,
});
- expect(getIp(requestHeaders, { advanced: { ipAddress: BETTER_AUTH_IP_ADDRESS_CONFIG } } as never)).toBe(
+ expect(getIP(requestHeaders, { advanced: { ipAddress: BETTER_AUTH_IP_ADDRESS_CONFIG } } as never)).toBe(
"2001:0db8:abcd:0012:0000:0000:0000:0000"
);
});
diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json
index 22a48bff9c78..5f11e13c2b13 100644
--- a/apps/web/locales/de-DE.json
+++ b/apps/web/locales/de-DE.json
@@ -217,7 +217,6 @@
"code": "Code",
"collapse_rows": "Zeilen einklappen",
"column_n": "Spalte {n}",
- "coming_soon": "Coming soon",
"completed": "Abgeschlossen",
"confirm": "Bestätigen",
"connect": "Verbinden",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "Bereits im Dashboard",
"and_filter_logic": "UND",
"apply_changes": "Änderungen übernehmen",
+ "bar_direction": "Balkenrichtung",
"chart": "Diagramm",
"chart_added_to_dashboard": "Diagramm zum Dashboard hinzugefügt!",
"chart_data": "Diagrammdaten",
"chart_data_tab": "Daten",
"chart_deleted_successfully": "Diagramm erfolgreich gelöscht",
"chart_deletion_error": "Diagramm konnte nicht gelöscht werden",
+ "chart_display_settings": "Diagramm-Anzeigeeinstellungen",
"chart_duplicated_successfully": "Diagramm erfolgreich dupliziert",
"chart_duplication_error": "Diagramm konnte nicht dupliziert werden",
"chart_name": "Diagrammname",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "Bist du sicher, dass du dieses Diagramm löschen möchtest?",
"dimensions": "Dimensionen",
"dimensions_toggle_description": "Gruppiere Daten nach Stimmung, Fragetyp und anderen Dimensionen.",
+ "distribution_segment_label": "{label}: {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "Sieh dir deine Diagrammkonfiguration an und bearbeite sie.",
"edit_chart_title": "Diagramm bearbeiten",
"edit_chart_title_named": "„{name}“ bearbeiten",
@@ -1784,6 +1787,7 @@
"group_by": "Gruppieren nach",
"group_by_description": "Schlüssle deine Daten nach einer oder mehreren Dimensionen auf (die Reihenfolge ist wichtig).",
"group_data": "Daten gruppieren",
+ "horizontal_bars": "Horizontale Balken",
"is_not_set": "ist nicht festgelegt",
"is_set": "ist festgelegt",
"language_value_unspecified": "Nicht angegeben",
@@ -1813,6 +1817,9 @@
"open_options": "Diagrammoptionen öffnen",
"or_filter_logic": "ODER",
"original": "Original",
+ "pie_display": "Anzeigen als",
+ "pie_display_breakdown": "Aufschlüsselungsbalken",
+ "pie_display_pie": "Kreisdiagramm",
"please_enter_chart_name": "Bitte gib einen Diagrammnamen ein",
"please_select_dashboard": "Bitte wähle ein Dashboard aus",
"predefined_measures": "Vordefinierte Kennzahlen",
@@ -1840,7 +1847,8 @@
"start_date": "Startdatum",
"time_dimension": "Zeitdimension",
"time_dimension_title": "Zeitbasierte Gruppierung hinzufügen",
- "time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf."
+ "time_dimension_toggle_description": "Beobachte Trends im Zeitverlauf.",
+ "vertical_bars": "Vertikale Balken"
},
"dashboards": {
"add_count_charts": "{count} Diagramm(e) hinzufügen",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "Diese Umfrage und alle zugehörigen Antworten werden nach 30 Tagen endgültig gelöscht.",
"archiving_survey": "Umfrage wird archiviert...",
"change_status": "Status ändern",
+ "completed_responses": "Abgeschlossen",
"copy_survey": "Umfrage kopieren",
"copy_survey_description": "Wähle einen Workspace aus, in den du diese Umfrage kopieren möchtest.",
"copy_survey_error": "Umfrage konnte nicht kopiert werden",
@@ -3953,7 +3962,6 @@
"allowed_values": "Zulässige Werte: {values}",
"api_ingestion": "API-Erfassung",
"api_ingestion_settings_description": "Erstelle Feedback-Einträge über die Management API",
- "api_ingestion_setup_description": "Nutze die REST API, um Feedback-Datensätze direkt an Formbricks zu senden. Die API-Ingestion-Docs enthalten den Endpunkt, die Payload-Struktur und Authentifizierungsdetails.",
"auto_generated": "Automatisch generiert",
"change_file": "Datei ändern",
"clear_mapping": "Zuordnung löschen",
diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json
index 76f146b81f4f..9dde412d99cd 100644
--- a/apps/web/locales/en-US.json
+++ b/apps/web/locales/en-US.json
@@ -1646,12 +1646,14 @@
"already_on_dashboard": "Already on dashboard",
"and_filter_logic": "AND",
"apply_changes": "Apply Changes",
+ "bar_direction": "Bar direction",
"chart": "Chart",
"chart_added_to_dashboard": "Chart added to dashboard!",
"chart_data": "Chart Data",
"chart_data_tab": "Data",
"chart_deleted_successfully": "Chart deleted successfully",
"chart_deletion_error": "Failed to delete chart",
+ "chart_display_settings": "Chart display settings",
"chart_duplicated_successfully": "Chart duplicated successfully",
"chart_duplication_error": "Failed to duplicate chart",
"chart_name": "Chart Name",
@@ -1693,6 +1695,8 @@
"delete_chart_confirmation": "Are you sure you want to delete this chart?",
"dimensions": "Dimensions",
"dimensions_toggle_description": "Group data by sentiment, question type, and other dimensions.",
+ "distribution_segment_label": "{label}: {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "View and edit your chart configuration.",
"edit_chart_title": "Edit Chart",
"edit_chart_title_named": "Edit \"{name}\"",
@@ -1783,6 +1787,7 @@
"group_by": "Group By",
"group_by_description": "Break down your data by one or more dimensions (order matters).",
"group_data": "Group data",
+ "horizontal_bars": "Horizontal bars",
"is_not_set": "is not set",
"is_set": "is set",
"language_value_unspecified": "Not specified",
@@ -1812,6 +1817,9 @@
"open_options": "Open chart options",
"or_filter_logic": "OR",
"original": "Original",
+ "pie_display": "Display as",
+ "pie_display_breakdown": "Breakdown bars",
+ "pie_display_pie": "Pie chart",
"please_enter_chart_name": "Please enter a chart name",
"please_select_dashboard": "Please select a dashboard",
"predefined_measures": "Predefined Measures",
@@ -1839,7 +1847,8 @@
"start_date": "Start date",
"time_dimension": "Time Dimension",
"time_dimension_title": "Add time-based grouping",
- "time_dimension_toggle_description": "Monitor trends over time."
+ "time_dimension_toggle_description": "Monitor trends over time.",
+ "vertical_bars": "Vertical bars"
},
"dashboards": {
"add_count_charts": "Add {count} chart(s)",
@@ -2994,6 +3003,7 @@
"archive_survey_warning": "This survey and all its responses will be permanently deleted after 30 days.",
"archiving_survey": "Archiving survey...",
"change_status": "Change status",
+ "completed_responses": "Completed",
"copy_survey": "Copy survey",
"copy_survey_description": "Choose a workspace to copy this survey to.",
"copy_survey_error": "Failed to copy survey",
diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json
index a22ea1507bec..28ca5c1bb5bd 100644
--- a/apps/web/locales/es-ES.json
+++ b/apps/web/locales/es-ES.json
@@ -217,7 +217,6 @@
"code": "Código",
"collapse_rows": "Contraer filas",
"column_n": "Columna {n}",
- "coming_soon": "Coming soon",
"completed": "Completado",
"confirm": "Confirmar",
"connect": "Conectar",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "Ya está en el panel",
"and_filter_logic": "Y",
"apply_changes": "Aplicar cambios",
+ "bar_direction": "Dirección de las barras",
"chart": "Gráfico",
"chart_added_to_dashboard": "¡Gráfico añadido al panel de control!",
"chart_data": "Datos del gráfico",
"chart_data_tab": "Datos",
"chart_deleted_successfully": "Gráfico eliminado correctamente",
"chart_deletion_error": "Error al eliminar el gráfico",
+ "chart_display_settings": "Configuración de visualización del gráfico",
"chart_duplicated_successfully": "Gráfico duplicado correctamente",
"chart_duplication_error": "Error al duplicar el gráfico",
"chart_name": "Nombre del gráfico",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "¿Estás seguro de que quieres eliminar este gráfico?",
"dimensions": "Dimensiones",
"dimensions_toggle_description": "Agrupa los datos por sentimiento, tipo de pregunta y otras dimensiones.",
+ "distribution_segment_label": "{label}: {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "Visualiza y edita la configuración de tu gráfico.",
"edit_chart_title": "Editar gráfico",
"edit_chart_title_named": "Editar \"{name}\"",
@@ -1784,6 +1787,7 @@
"group_by": "Agrupar por",
"group_by_description": "Desglosa tus datos por una o más dimensiones (el orden importa).",
"group_data": "Agrupar datos",
+ "horizontal_bars": "Barras horizontales",
"is_not_set": "no está establecido",
"is_set": "está establecido",
"language_value_unspecified": "No especificado",
@@ -1813,6 +1817,9 @@
"open_options": "Abrir opciones del gráfico",
"or_filter_logic": "O",
"original": "Original",
+ "pie_display": "Mostrar como",
+ "pie_display_breakdown": "Barras de desglose",
+ "pie_display_pie": "Gráfico circular",
"please_enter_chart_name": "Introduce un nombre para el gráfico",
"please_select_dashboard": "Selecciona un panel de control",
"predefined_measures": "Medidas predefinidas",
@@ -1840,7 +1847,8 @@
"start_date": "Fecha de inicio",
"time_dimension": "Dimensión temporal",
"time_dimension_title": "Añadir agrupación temporal",
- "time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo."
+ "time_dimension_toggle_description": "Supervisa las tendencias a lo largo del tiempo.",
+ "vertical_bars": "Barras verticales"
},
"dashboards": {
"add_count_charts": "Añadir {count} gráfico(s)",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "Esta encuesta y todas sus respuestas se eliminarán permanentemente después de 30 días.",
"archiving_survey": "Archivando encuesta...",
"change_status": "Cambiar estado",
+ "completed_responses": "Completadas",
"copy_survey": "Copiar encuesta",
"copy_survey_description": "Elige un espacio de trabajo para copiar esta encuesta.",
"copy_survey_error": "Error al copiar la encuesta",
@@ -3953,7 +3962,6 @@
"allowed_values": "Valores permitidos: {values}",
"api_ingestion": "Ingesta de API",
"api_ingestion_settings_description": "Crea registros de feedback usando la API de Gestión",
- "api_ingestion_setup_description": "Utiliza la API REST para enviar registros de feedback directamente a Formbricks. La documentación de ingesta de API incluye el endpoint, la estructura del payload y los detalles de autenticación.",
"auto_generated": "Generado automáticamente",
"change_file": "Cambiar archivo",
"clear_mapping": "Borrar asignación",
diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json
index de7b424092f5..da0ab98fa758 100644
--- a/apps/web/locales/fr-FR.json
+++ b/apps/web/locales/fr-FR.json
@@ -217,7 +217,6 @@
"code": "Code",
"collapse_rows": "Réduire les lignes",
"column_n": "Colonne {n}",
- "coming_soon": "Coming soon",
"completed": "Terminé",
"confirm": "Confirmer",
"connect": "Connecter",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "Déjà sur le tableau de bord",
"and_filter_logic": "ET",
"apply_changes": "Appliquer les modifications",
+ "bar_direction": "Direction des barres",
"chart": "Graphique",
"chart_added_to_dashboard": "Graphique ajouté au tableau de bord !",
"chart_data": "Données du graphique",
"chart_data_tab": "Données",
"chart_deleted_successfully": "Graphique supprimé avec succès",
"chart_deletion_error": "Échec de la suppression du graphique",
+ "chart_display_settings": "Paramètres d'affichage du graphique",
"chart_duplicated_successfully": "Graphique dupliqué avec succès",
"chart_duplication_error": "Échec de la duplication du graphique",
"chart_name": "Nom du graphique",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "Êtes-vous sûr de vouloir supprimer ce graphique ?",
"dimensions": "Dimensions",
"dimensions_toggle_description": "Groupe les données par sentiment, type de question et autres dimensions.",
+ "distribution_segment_label": "{label} : {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "Consultez et modifiez la configuration de votre graphique.",
"edit_chart_title": "Modifier le graphique",
"edit_chart_title_named": "Modifier \"{name}\"",
@@ -1784,6 +1787,7 @@
"group_by": "Regrouper par",
"group_by_description": "Décompose tes données selon une ou plusieurs dimensions (l'ordre compte).",
"group_data": "Grouper les données",
+ "horizontal_bars": "Barres horizontales",
"is_not_set": "n'est pas défini",
"is_set": "est défini",
"language_value_unspecified": "Non spécifié",
@@ -1813,6 +1817,9 @@
"open_options": "Ouvrir les options du graphique",
"or_filter_logic": "OU",
"original": "Original",
+ "pie_display": "Afficher en tant que",
+ "pie_display_breakdown": "Barres de répartition",
+ "pie_display_pie": "Graphique en secteurs",
"please_enter_chart_name": "Veuillez saisir un nom de graphique",
"please_select_dashboard": "Veuillez sélectionner un tableau de bord",
"predefined_measures": "Mesures prédéfinies",
@@ -1840,7 +1847,8 @@
"start_date": "Date de début",
"time_dimension": "Dimension temporelle",
"time_dimension_title": "Ajouter un groupement temporel",
- "time_dimension_toggle_description": "Surveille les tendances dans le temps."
+ "time_dimension_toggle_description": "Surveille les tendances dans le temps.",
+ "vertical_bars": "Barres verticales"
},
"dashboards": {
"add_count_charts": "Ajouter {count} graphique(s)",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "Ce sondage et toutes ses réponses seront définitivement supprimés après 30 jours.",
"archiving_survey": "Archivage du sondage...",
"change_status": "Changer le statut",
+ "completed_responses": "Terminés",
"copy_survey": "Copier l'enquête",
"copy_survey_description": "Choisis un espace de travail vers lequel copier cette enquête.",
"copy_survey_error": "Échec de la copie de l'enquête",
@@ -3953,7 +3962,6 @@
"allowed_values": "Valeurs autorisées : {values}",
"api_ingestion": "Ingestion par API",
"api_ingestion_settings_description": "Crée des retours en utilisant l'API de gestion",
- "api_ingestion_setup_description": "Utilise l'API REST pour envoyer directement les retours d'expérience dans Formbricks. La documentation sur l'ingestion API inclut le point de terminaison, la structure de la charge utile et les détails d'authentification.",
"auto_generated": "Généré automatiquement",
"change_file": "Changer de fichier",
"clear_mapping": "Effacer le mappage",
diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json
index d025bd888cf4..4a5fae6840c0 100644
--- a/apps/web/locales/hu-HU.json
+++ b/apps/web/locales/hu-HU.json
@@ -217,7 +217,6 @@
"code": "Kód",
"collapse_rows": "Sorok összecsukása",
"column_n": "{n}. oszlop",
- "coming_soon": "Coming soon",
"completed": "Befejezve",
"confirm": "Megerősítés",
"connect": "Kapcsolódás",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "Már a vezérlőpulton van",
"and_filter_logic": "ÉS",
"apply_changes": "Változtatások alkalmazása",
+ "bar_direction": "Oszlopok iránya",
"chart": "Diagram",
"chart_added_to_dashboard": "A diagram hozzáadva a vezérlőpulthoz!",
- "chart_data": "Diagramadatok",
+ "chart_data": "Diagram adatai",
"chart_data_tab": "Adatok",
"chart_deleted_successfully": "A diagram sikeresen törölve",
"chart_deletion_error": "Nem sikerült törölni a diagramot",
+ "chart_display_settings": "Diagram megjelenítési beállítások",
"chart_duplicated_successfully": "A diagram sikeresen megkettőzve",
"chart_duplication_error": "Nem sikerült kettőzni a diagramot",
"chart_name": "Diagram neve",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "Biztosan törölni szeretné ezt a diagramot?",
"dimensions": "Dimenziók",
"dimensions_toggle_description": "Adatok csoportosítása hangulat, kérdéstípus és egyéb dimenziók szerint.",
+ "distribution_segment_label": "{label}: {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "Diagram beállításainak megtekintése és szerkesztése.",
"edit_chart_title": "Diagram szerkesztése",
"edit_chart_title_named": "„{name}“ szerkesztése",
@@ -1784,6 +1787,7 @@
"group_by": "Csoportosítás",
"group_by_description": "Bontsa le adatait egy vagy több dimenzió szerint (a sorrend számít).",
"group_data": "Adatok csoportosítása",
+ "horizontal_bars": "Vízszintes oszlopok",
"is_not_set": "nincs beállítva",
"is_set": "be van állítva",
"language_value_unspecified": "Nem megadott",
@@ -1813,6 +1817,9 @@
"open_options": "Diagram beállításainak megnyitása",
"or_filter_logic": "VAGY",
"original": "Eredeti",
+ "pie_display": "Megjelenítés módja",
+ "pie_display_breakdown": "Lebontási sávok",
+ "pie_display_pie": "Kördiagram",
"please_enter_chart_name": "Adjon meg egy diagramnevet",
"please_select_dashboard": "Válasszon egy vezérlőpultot",
"predefined_measures": "Előre meghatározott mérések",
@@ -1840,7 +1847,8 @@
"start_date": "Kezdési dátum",
"time_dimension": "Idődimenzió",
"time_dimension_title": "Időalapú csoportosítás hozzáadása",
- "time_dimension_toggle_description": "Időbeni trendek megfigyelése."
+ "time_dimension_toggle_description": "Időbeni trendek megfigyelése.",
+ "vertical_bars": "Függőleges oszlopok"
},
"dashboards": {
"add_count_charts": "{count} diagram hozzáadása",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "Ez a felmérés és az összes válasz véglegesen törlésre kerül 30 nap elteltével.",
"archiving_survey": "Felmérés archiválása folyamatban...",
"change_status": "Állapot módosítása",
+ "completed_responses": "Kitöltve",
"copy_survey": "Felmérés másolása",
"copy_survey_description": "Válasszon ki egy munkaterületet, amelyre ezt a felmérést másolni szeretné.",
"copy_survey_error": "A felmérés másolása sikertelen volt",
@@ -3953,7 +3962,6 @@
"allowed_values": "Engedélyezett értékek: {values}",
"api_ingestion": "API-befogadás",
"api_ingestion_settings_description": "Visszajelzési rekordok létrehozása a Management API használatával",
- "api_ingestion_setup_description": "Használja a REST API-t, hogy visszajelzési rekordokat küldjön közvetlenül a Formbricksbe. Az API-befogadási dokumentáció tartalmazza a végpontot, az adatcsomag szerkezetét és a hitelesítési részleteket.",
"auto_generated": "Automatikusan előállítva",
"change_file": "Fájl megváltoztatása",
"clear_mapping": "Leképezés törlése",
diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json
index f8ab8f5c8885..09aa1501dff5 100644
--- a/apps/web/locales/ja-JP.json
+++ b/apps/web/locales/ja-JP.json
@@ -217,7 +217,6 @@
"code": "コード",
"collapse_rows": "行を非表示",
"column_n": "列 {n}",
- "coming_soon": "Coming soon",
"completed": "完了",
"confirm": "確認",
"connect": "接続",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "すでにダッシュボードに追加済み",
"and_filter_logic": "AND",
"apply_changes": "変更を適用",
+ "bar_direction": "棒の向き",
"chart": "チャート",
"chart_added_to_dashboard": "チャートをダッシュボードに追加しました!",
"chart_data": "チャートデータ",
"chart_data_tab": "データ",
"chart_deleted_successfully": "チャートを削除しました",
"chart_deletion_error": "チャートの削除に失敗しました",
+ "chart_display_settings": "グラフ表示設定",
"chart_duplicated_successfully": "チャートを複製しました",
"chart_duplication_error": "チャートの複製に失敗しました",
"chart_name": "チャート名",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "このチャートを削除してもよろしいですか?",
"dimensions": "ディメンション",
"dimensions_toggle_description": "センチメント、質問タイプ、その他のディメンションでデータをグループ化します。",
+ "distribution_segment_label": "{label}: {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "チャート設定を表示および編集します。",
"edit_chart_title": "チャートを編集",
"edit_chart_title_named": "「{name}」を編集",
@@ -1784,6 +1787,7 @@
"group_by": "グループ化",
"group_by_description": "1つ以上のディメンションでデータを分類できます(順序が重要です)。",
"group_data": "データをグループ化",
+ "horizontal_bars": "横棒",
"is_not_set": "設定されていない",
"is_set": "設定されている",
"language_value_unspecified": "未指定",
@@ -1813,6 +1817,9 @@
"open_options": "チャートオプションを開く",
"or_filter_logic": "OR",
"original": "オリジナル",
+ "pie_display": "表示形式",
+ "pie_display_breakdown": "内訳バー",
+ "pie_display_pie": "円グラフ",
"please_enter_chart_name": "チャート名を入力してください",
"please_select_dashboard": "ダッシュボードを選択してください",
"predefined_measures": "事前定義されたメジャー",
@@ -1840,7 +1847,8 @@
"start_date": "開始日",
"time_dimension": "時間ディメンション",
"time_dimension_title": "時間ベースのグループ化を追加",
- "time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。"
+ "time_dimension_toggle_description": "時間の経過に伴うトレンドを監視します。",
+ "vertical_bars": "縦棒"
},
"dashboards": {
"add_count_charts": "{count}個のグラフを追加",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "このアンケートとすべての回答は30日後に完全に削除されます。",
"archiving_survey": "アンケートをアーカイブ中...",
"change_status": "ステータスを変更",
+ "completed_responses": "完了",
"copy_survey": "アンケートをコピー",
"copy_survey_description": "このアンケートをコピーするワークスペースを選択してください。",
"copy_survey_error": "アンケートのコピーに失敗しました",
@@ -3953,7 +3962,6 @@
"allowed_values": "許可される値: {values}",
"api_ingestion": "API取り込み",
"api_ingestion_settings_description": "Management APIを使用してフィードバック記録を作成",
- "api_ingestion_setup_description": "REST APIを使用して、フィードバックレコードをFormbricksに直接送信できます。APIインジェストのドキュメントには、エンドポイント、ペイロード形式、認証の詳細が含まれています。",
"auto_generated": "自動生成",
"change_file": "ファイルを変更",
"clear_mapping": "マッピングをクリア",
diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json
index df5a3f8a57fc..2afbe568274c 100644
--- a/apps/web/locales/nl-NL.json
+++ b/apps/web/locales/nl-NL.json
@@ -217,7 +217,6 @@
"code": "Code",
"collapse_rows": "Rijen samenvouwen",
"column_n": "Kolom {n}",
- "coming_soon": "Coming soon",
"completed": "Voltooid",
"confirm": "Bevestigen",
"connect": "Verbinden",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "Al op dashboard",
"and_filter_logic": "EN",
"apply_changes": "Wijzigingen toepassen",
+ "bar_direction": "Staafrichting",
"chart": "Grafiek",
"chart_added_to_dashboard": "Grafiek toegevoegd aan dashboard!",
- "chart_data": "Grafiekdata",
- "chart_data_tab": "Data",
+ "chart_data": "Grafiekgegevens",
+ "chart_data_tab": "Gegevens",
"chart_deleted_successfully": "Grafiek succesvol verwijderd",
"chart_deletion_error": "Verwijderen van grafiek mislukt",
+ "chart_display_settings": "Grafiekweergave-instellingen",
"chart_duplicated_successfully": "Grafiek succesvol gedupliceerd",
"chart_duplication_error": "Dupliceren van grafiek mislukt",
"chart_name": "Grafieknaam",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "Weet je zeker dat je deze grafiek wilt verwijderen?",
"dimensions": "Dimensies",
"dimensions_toggle_description": "Groepeer data op sentiment, vraagtype en andere dimensies.",
+ "distribution_segment_label": "{label}: {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "Bekijk en bewerk je diagramconfiguratie.",
"edit_chart_title": "Diagram bewerken",
"edit_chart_title_named": "Bewerk \"{name}\"",
@@ -1784,6 +1787,7 @@
"group_by": "Groeperen op",
"group_by_description": "Splits je data op volgens een of meer dimensies (volgorde is belangrijk).",
"group_data": "Data groeperen",
+ "horizontal_bars": "Horizontale staven",
"is_not_set": "is niet ingesteld",
"is_set": "is ingesteld",
"language_value_unspecified": "Niet gespecificeerd",
@@ -1813,6 +1817,9 @@
"open_options": "Open diagramopties",
"or_filter_logic": "OF",
"original": "Origineel",
+ "pie_display": "Weergeven als",
+ "pie_display_breakdown": "Uitgesplitste balken",
+ "pie_display_pie": "Taartdiagram",
"please_enter_chart_name": "Voer een diagramnaam in",
"please_select_dashboard": "Selecteer een dashboard",
"predefined_measures": "Vooraf gedefinieerde metingen",
@@ -1840,7 +1847,8 @@
"start_date": "Startdatum",
"time_dimension": "Tijdsdimensie",
"time_dimension_title": "Tijdgebaseerde groepering toevoegen",
- "time_dimension_toggle_description": "Volg trends over tijd."
+ "time_dimension_toggle_description": "Volg trends over tijd.",
+ "vertical_bars": "Verticale staven"
},
"dashboards": {
"add_count_charts": "{count} grafiek(en) toevoegen",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "Deze enquête en alle bijbehorende reacties worden na 30 dagen definitief verwijderd.",
"archiving_survey": "Enquête wordt gearchiveerd...",
"change_status": "Status wijzigen",
+ "completed_responses": "Voltooid",
"copy_survey": "Enquête kopiëren",
"copy_survey_description": "Kies een workspace waarnaar je deze enquête wilt kopiëren.",
"copy_survey_error": "Enquête kopiëren mislukt",
@@ -3953,7 +3962,6 @@
"allowed_values": "Toegestane waarden: {values}",
"api_ingestion": "API-inname",
"api_ingestion_settings_description": "Maak feedbackgegevens aan via de Management API",
- "api_ingestion_setup_description": "Gebruik de REST API om feedbackgegevens rechtstreeks naar Formbricks te sturen. De API-ingestiedocumentatie bevat het endpoint, de payload-structuur en authenticatiegegevens.",
"auto_generated": "Automatisch gegenereerd",
"change_file": "Bestand wijzigen",
"clear_mapping": "Mapping wissen",
diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json
index 22c7d18020aa..8e5625d4106e 100644
--- a/apps/web/locales/pt-BR.json
+++ b/apps/web/locales/pt-BR.json
@@ -217,7 +217,6 @@
"code": "Código",
"collapse_rows": "Recolher linhas",
"column_n": "Coluna {n}",
- "coming_soon": "Coming soon",
"completed": "Concluído",
"confirm": "Confirmar",
"connect": "Conectar",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "Já está no painel",
"and_filter_logic": "E",
"apply_changes": "Aplicar alterações",
+ "bar_direction": "Direção das barras",
"chart": "Gráfico",
"chart_added_to_dashboard": "Gráfico adicionado ao painel!",
- "chart_data": "Dados do gráfico",
+ "chart_data": "Dados do Gráfico",
"chart_data_tab": "Dados",
"chart_deleted_successfully": "Gráfico excluído com sucesso",
"chart_deletion_error": "Falha ao excluir gráfico",
+ "chart_display_settings": "Configurações de exibição do gráfico",
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
"chart_duplication_error": "Falha ao duplicar gráfico",
"chart_name": "Nome do gráfico",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "Tem certeza de que deseja excluir este gráfico?",
"dimensions": "Dimensões",
"dimensions_toggle_description": "Agrupe dados por sentimento, tipo de pergunta e outras dimensões.",
+ "distribution_segment_label": "{label}: {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "Visualize e edite a configuração do seu gráfico.",
"edit_chart_title": "Editar gráfico",
"edit_chart_title_named": "Editar \"{name}\"",
@@ -1784,6 +1787,7 @@
"group_by": "Agrupar por",
"group_by_description": "Divida seus dados por uma ou mais dimensões (a ordem importa).",
"group_data": "Agrupar dados",
+ "horizontal_bars": "Barras horizontais",
"is_not_set": "não está definido",
"is_set": "está definido",
"language_value_unspecified": "Não especificado",
@@ -1813,6 +1817,9 @@
"open_options": "Abrir opções do gráfico",
"or_filter_logic": "OU",
"original": "Original",
+ "pie_display": "Exibir como",
+ "pie_display_breakdown": "Barras de detalhamento",
+ "pie_display_pie": "Gráfico de pizza",
"please_enter_chart_name": "Por favor, insira um nome para o gráfico",
"please_select_dashboard": "Por favor, selecione um painel",
"predefined_measures": "Medidas predefinidas",
@@ -1840,7 +1847,8 @@
"start_date": "Data inicial",
"time_dimension": "Dimensão temporal",
"time_dimension_title": "Adicionar agrupamento por tempo",
- "time_dimension_toggle_description": "Monitore tendências ao longo do tempo."
+ "time_dimension_toggle_description": "Monitore tendências ao longo do tempo.",
+ "vertical_bars": "Barras verticais"
},
"dashboards": {
"add_count_charts": "Adicionar {count} gráfico(s)",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "Esta pesquisa e todas as suas respostas serão excluídas permanentemente após 30 dias.",
"archiving_survey": "Arquivando pesquisa...",
"change_status": "Alterar status",
+ "completed_responses": "Concluídas",
"copy_survey": "Copiar pesquisa",
"copy_survey_description": "Escolha um espaço de trabalho para copiar esta pesquisa.",
"copy_survey_error": "Falha ao copiar pesquisa",
@@ -3953,7 +3962,6 @@
"allowed_values": "Valores permitidos: {values}",
"api_ingestion": "Ingestão de API",
"api_ingestion_settings_description": "Crie registros de feedback usando a API de Gerenciamento",
- "api_ingestion_setup_description": "Use a API REST para enviar registros de feedback diretamente para o Formbricks. A documentação de ingestão da API inclui o endpoint, a estrutura do payload e detalhes de autenticação.",
"auto_generated": "Gerado automaticamente",
"change_file": "Alterar arquivo",
"clear_mapping": "Limpar mapeamento",
diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json
index fa14d59546c3..7a9d3a7a9d5e 100644
--- a/apps/web/locales/pt-PT.json
+++ b/apps/web/locales/pt-PT.json
@@ -217,7 +217,6 @@
"code": "Código",
"collapse_rows": "Recolher linhas",
"column_n": "Coluna {n}",
- "coming_soon": "Coming soon",
"completed": "Concluído",
"confirm": "Confirmar",
"connect": "Conectar",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "Já está no painel",
"and_filter_logic": "E",
"apply_changes": "Aplicar alterações",
+ "bar_direction": "Direção das barras",
"chart": "Gráfico",
"chart_added_to_dashboard": "Gráfico adicionado ao painel!",
- "chart_data": "Dados do gráfico",
+ "chart_data": "Dados do Gráfico",
"chart_data_tab": "Dados",
"chart_deleted_successfully": "Gráfico eliminado com sucesso",
"chart_deletion_error": "Falha ao eliminar gráfico",
+ "chart_display_settings": "Definições de visualização do gráfico",
"chart_duplicated_successfully": "Gráfico duplicado com sucesso",
"chart_duplication_error": "Falha ao duplicar gráfico",
"chart_name": "Nome do gráfico",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "Tens a certeza de que queres eliminar este gráfico?",
"dimensions": "Dimensões",
"dimensions_toggle_description": "Agrupa dados por sentimento, tipo de pergunta e outras dimensões.",
+ "distribution_segment_label": "{label}: {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "Visualize e edite a configuração do seu gráfico.",
"edit_chart_title": "Editar gráfico",
"edit_chart_title_named": "Editar \"{name}\"",
@@ -1784,6 +1787,7 @@
"group_by": "Agrupar por",
"group_by_description": "Divide os teus dados por uma ou mais dimensões (a ordem é importante).",
"group_data": "Agrupar dados",
+ "horizontal_bars": "Barras horizontais",
"is_not_set": "não está definido",
"is_set": "está definido",
"language_value_unspecified": "Não especificado",
@@ -1813,6 +1817,9 @@
"open_options": "Abrir opções do gráfico",
"or_filter_logic": "OU",
"original": "Original",
+ "pie_display": "Apresentar como",
+ "pie_display_breakdown": "Barras de discriminação",
+ "pie_display_pie": "Gráfico circular",
"please_enter_chart_name": "Por favor, introduz um nome para o gráfico",
"please_select_dashboard": "Por favor, seleciona um painel",
"predefined_measures": "Medidas predefinidas",
@@ -1840,7 +1847,8 @@
"start_date": "Data de início",
"time_dimension": "Dimensão temporal",
"time_dimension_title": "Adicionar agrupamento temporal",
- "time_dimension_toggle_description": "Monitoriza tendências ao longo do tempo."
+ "time_dimension_toggle_description": "Monitoriza tendências ao longo do tempo.",
+ "vertical_bars": "Barras verticais"
},
"dashboards": {
"add_count_charts": "Adicionar {count} gráfico(s)",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "Este inquérito e todas as suas respostas serão permanentemente eliminados após 30 dias.",
"archiving_survey": "A arquivar inquérito...",
"change_status": "Alterar estado",
+ "completed_responses": "Concluídos",
"copy_survey": "Copiar inquérito",
"copy_survey_description": "Escolhe um espaço de trabalho para copiar este inquérito.",
"copy_survey_error": "Falha ao copiar inquérito",
@@ -3953,7 +3962,6 @@
"allowed_values": "Valores permitidos: {values}",
"api_ingestion": "Ingestão de API",
"api_ingestion_settings_description": "Cria registos de feedback através da API de Gestão",
- "api_ingestion_setup_description": "Usa a REST API para enviar registos de feedback diretamente para o Formbricks. A documentação da API de ingestão inclui o endpoint, a estrutura do payload e os detalhes de autenticação.",
"auto_generated": "Gerado automaticamente",
"change_file": "Alterar ficheiro",
"clear_mapping": "Limpar mapeamento",
diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json
index 8df8e41abd84..11555a70f1c5 100644
--- a/apps/web/locales/ro-RO.json
+++ b/apps/web/locales/ro-RO.json
@@ -217,7 +217,6 @@
"code": "Cod",
"collapse_rows": "Restrânge rânduri",
"column_n": "Coloana {n}",
- "coming_soon": "Coming soon",
"completed": "Completat",
"confirm": "Confirmare",
"connect": "Conectează",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "Deja pe tabloul de bord",
"and_filter_logic": "ȘI",
"apply_changes": "Aplică modificările",
- "chart": "Grafic",
+ "bar_direction": "Direcția barelor",
+ "chart": "Diagramă",
"chart_added_to_dashboard": "Grafic adăugat la tablou de bord!",
- "chart_data": "Datele graficului",
+ "chart_data": "Date diagramă",
"chart_data_tab": "Date",
"chart_deleted_successfully": "Graficul a fost șters cu succes",
"chart_deletion_error": "Nu s-a putut șterge graficul",
+ "chart_display_settings": "Setări afișare grafic",
"chart_duplicated_successfully": "Graficul a fost duplicat cu succes",
"chart_duplication_error": "Nu s-a putut duplica graficul",
"chart_name": "Numele graficului",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "Ești sigur că vrei să ștergi acest grafic?",
"dimensions": "Dimensiuni",
"dimensions_toggle_description": "Grupează datele după sentiment, tipul întrebării și alte dimensiuni.",
+ "distribution_segment_label": "{label}: {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "Vezi și editează configurația graficului tău.",
"edit_chart_title": "Editează graficul",
"edit_chart_title_named": "Editează \"{name}\"",
@@ -1784,6 +1787,7 @@
"group_by": "Grupează după",
"group_by_description": "Descompune datele tale pe baza uneia sau mai multor dimensiuni (ordinea contează).",
"group_data": "Grupează datele",
+ "horizontal_bars": "bare orizontale",
"is_not_set": "nu este setat",
"is_set": "este setat",
"language_value_unspecified": "Nespecificat",
@@ -1813,6 +1817,9 @@
"open_options": "Deschide opțiunile graficului",
"or_filter_logic": "SAU",
"original": "Original",
+ "pie_display": "Afișează ca",
+ "pie_display_breakdown": "Bare de detaliere",
+ "pie_display_pie": "Diagramă circulară",
"please_enter_chart_name": "Te rugăm să introduci un nume pentru grafic",
"please_select_dashboard": "Te rugăm să selectezi un tablou de bord",
"predefined_measures": "Măsurători predefinite",
@@ -1840,7 +1847,8 @@
"start_date": "Data de început",
"time_dimension": "Dimensiune temporală",
"time_dimension_title": "Adaugă grupare pe bază de timp",
- "time_dimension_toggle_description": "Monitorizează tendințele în timp."
+ "time_dimension_toggle_description": "Monitorizează tendințele în timp.",
+ "vertical_bars": "bare verticale"
},
"dashboards": {
"add_count_charts": "Adaugă {count} grafic(e)",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "Acest sondaj și toate răspunsurile sale vor fi șterse definitiv după 30 de zile.",
"archiving_survey": "Se arhivează sondajul...",
"change_status": "Schimbă status",
+ "completed_responses": "Finalizate",
"copy_survey": "Copiază chestionarul",
"copy_survey_description": "Alege un spațiu de lucru în care să copiezi acest chestionar.",
"copy_survey_error": "Copierea chestionarului a eșuat",
@@ -3953,7 +3962,6 @@
"allowed_values": "Valori permise: {values}",
"api_ingestion": "Ingestie API",
"api_ingestion_settings_description": "Creează înregistrări de feedback folosind Management API",
- "api_ingestion_setup_description": "Folosește REST API pentru a trimite înregistrări de feedback direct în Formbricks. Documentația de ingestie API include endpoint-ul, structura payload-ului și detaliile de autentificare.",
"auto_generated": "Generat automat",
"change_file": "Schimbă fișierul",
"clear_mapping": "Șterge maparea",
diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json
index f7da19701d19..300a05e70ebe 100644
--- a/apps/web/locales/ru-RU.json
+++ b/apps/web/locales/ru-RU.json
@@ -217,7 +217,6 @@
"code": "Код",
"collapse_rows": "Свернуть строки",
"column_n": "Столбец {n}",
- "coming_soon": "Coming soon",
"completed": "Завершено",
"confirm": "Подтвердить",
"connect": "Подключить",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "Уже на дашборде",
"and_filter_logic": "И",
"apply_changes": "Применить изменения",
+ "bar_direction": "Направление столбцов",
"chart": "График",
"chart_added_to_dashboard": "График добавлен на панель!",
"chart_data": "Данные графика",
"chart_data_tab": "Данные",
"chart_deleted_successfully": "График успешно удалён",
"chart_deletion_error": "Не удалось удалить график",
+ "chart_display_settings": "Настройки отображения диаграммы",
"chart_duplicated_successfully": "График успешно дублирован",
"chart_duplication_error": "Не удалось дублировать график",
"chart_name": "Название графика",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "Ты уверен, что хочешь удалить этот график?",
"dimensions": "Измерения",
"dimensions_toggle_description": "Группируйте данные по настроению, типу вопроса и другим измерениям.",
+ "distribution_segment_label": "{label}: {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "Просмотри и измени настройки своего графика.",
"edit_chart_title": "Редактировать график",
"edit_chart_title_named": "Редактировать «{name}»",
@@ -1784,6 +1787,7 @@
"group_by": "Группировать по",
"group_by_description": "Разбей свои данные по одному или нескольким измерениям (порядок имеет значение).",
"group_data": "Группировать данные",
+ "horizontal_bars": "Горизонтальные столбцы",
"is_not_set": "не задано",
"is_set": "задано",
"language_value_unspecified": "Не указано",
@@ -1813,6 +1817,9 @@
"open_options": "Открыть настройки графика",
"or_filter_logic": "ИЛИ",
"original": "Оригинал",
+ "pie_display": "Отображать как",
+ "pie_display_breakdown": "Столбчатая диаграмма",
+ "pie_display_pie": "Круговая диаграмма",
"please_enter_chart_name": "Пожалуйста, введи название графика",
"please_select_dashboard": "Пожалуйста, выбери панель управления",
"predefined_measures": "Предустановленные показатели",
@@ -1840,7 +1847,8 @@
"start_date": "Дата начала",
"time_dimension": "Временное измерение",
"time_dimension_title": "Добавить группировку по времени",
- "time_dimension_toggle_description": "Отслеживайте тренды с течением времени."
+ "time_dimension_toggle_description": "Отслеживайте тренды с течением времени.",
+ "vertical_bars": "Вертикальные столбцы"
},
"dashboards": {
"add_count_charts": "Добавить {count} график(ов)",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "Этот опрос и все его ответы будут безвозвратно удалены через 30 дней.",
"archiving_survey": "Архивируем опрос...",
"change_status": "Изменить статус",
+ "completed_responses": "Завершено",
"copy_survey": "Копировать опрос",
"copy_survey_description": "Выбери рабочее пространство, в которое хочешь скопировать этот опрос.",
"copy_survey_error": "Не удалось скопировать опрос",
@@ -3953,7 +3962,6 @@
"allowed_values": "Допустимые значения: {values}",
"api_ingestion": "Импорт через API",
"api_ingestion_settings_description": "Создавайте записи обратной связи с помощью Management API",
- "api_ingestion_setup_description": "Используйте REST API для прямой отправки записей отзывов в Formbricks. Документация по API включает конечную точку, структуру данных и сведения об аутентификации.",
"auto_generated": "Автоматически генерируется",
"change_file": "Изменить файл",
"clear_mapping": "Очистить сопоставление",
diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json
index f9afd544e9c7..21382decfe3b 100644
--- a/apps/web/locales/sv-SE.json
+++ b/apps/web/locales/sv-SE.json
@@ -217,7 +217,6 @@
"code": "Kod",
"collapse_rows": "Dölj rader",
"column_n": "Kolumn {n}",
- "coming_soon": "Coming soon",
"completed": "Slutförd",
"confirm": "Bekräfta",
"connect": "Anslut",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "Redan på instrumentpanelen",
"and_filter_logic": "OCH",
"apply_changes": "Verkställ ändringar",
+ "bar_direction": "Stapelriktning",
"chart": "Diagram",
"chart_added_to_dashboard": "Diagram tillagt på instrumentpanelen!",
"chart_data": "Diagramdata",
"chart_data_tab": "Data",
"chart_deleted_successfully": "Diagrammet har tagits bort",
"chart_deletion_error": "Det gick inte att ta bort diagrammet",
+ "chart_display_settings": "Diagramvisningsinställningar",
"chart_duplicated_successfully": "Diagrammet har duplicerats",
"chart_duplication_error": "Det gick inte att duplicera diagrammet",
"chart_name": "Diagramnamn",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "Är du säker på att du vill ta bort det här diagrammet?",
"dimensions": "Dimensioner",
"dimensions_toggle_description": "Gruppera data efter sentiment, frågetyp och andra dimensioner.",
+ "distribution_segment_label": "{label}: {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "Visa och redigera din diagramkonfiguration.",
"edit_chart_title": "Redigera diagram",
"edit_chart_title_named": "Redigera \"{name}\"",
@@ -1784,6 +1787,7 @@
"group_by": "Gruppera efter",
"group_by_description": "Dela upp din data efter en eller flera dimensioner (ordningen spelar roll).",
"group_data": "Gruppera data",
+ "horizontal_bars": "Horisontella staplar",
"is_not_set": "är inte satt",
"is_set": "är satt",
"language_value_unspecified": "Ej angivet",
@@ -1813,6 +1817,9 @@
"open_options": "Öppna diagramalternativ",
"or_filter_logic": "ELLER",
"original": "Original",
+ "pie_display": "Visa som",
+ "pie_display_breakdown": "Fördelningsstaplar",
+ "pie_display_pie": "Cirkeldiagram",
"please_enter_chart_name": "Ange ett diagramnamn",
"please_select_dashboard": "Välj en instrumentpanel",
"predefined_measures": "Fördefinierade mått",
@@ -1840,7 +1847,8 @@
"start_date": "Startdatum",
"time_dimension": "Tidsdimension",
"time_dimension_title": "Lägg till tidsbaserad gruppering",
- "time_dimension_toggle_description": "Övervaka trender över tid."
+ "time_dimension_toggle_description": "Övervaka trender över tid.",
+ "vertical_bars": "Vertikala staplar"
},
"dashboards": {
"add_count_charts": "Lägg till {count} diagram",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "Denna undersökning och alla dess svar kommer att raderas permanent efter 30 dagar.",
"archiving_survey": "Arkiverar undersökning...",
"change_status": "Ändra status",
+ "completed_responses": "Slutförda",
"copy_survey": "Kopiera undersökning",
"copy_survey_description": "Välj en arbetsyta att kopiera den här undersökningen till.",
"copy_survey_error": "Kunde inte kopiera undersökningen",
@@ -3953,7 +3962,6 @@
"allowed_values": "Tillåtna värden: {values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "Skapa feedbackposter med hjälp av Management API",
- "api_ingestion_setup_description": "Använd REST API för att skicka feedbackposter direkt till Formbricks. API-dokumentationen innehåller endpoint, datastruktur och autentiseringsdetaljer.",
"auto_generated": "Automatiskt genererad",
"change_file": "Byt fil",
"clear_mapping": "Rensa mappning",
diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json
index b4a99380663b..c2ab5dad463b 100644
--- a/apps/web/locales/tr-TR.json
+++ b/apps/web/locales/tr-TR.json
@@ -217,7 +217,6 @@
"code": "Kod",
"collapse_rows": "Satırları daralt",
"column_n": "Sütun {n}",
- "coming_soon": "Coming soon",
"completed": "Tamamlandı",
"confirm": "Onayla",
"connect": "Bağlan",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "Zaten panoda",
"and_filter_logic": "VE",
"apply_changes": "Değişiklikleri Uygula",
+ "bar_direction": "Çubuk yönü",
"chart": "Grafik",
"chart_added_to_dashboard": "Grafik panoya eklendi!",
- "chart_data": "Grafik Verisi",
+ "chart_data": "Grafik Verileri",
"chart_data_tab": "Veri",
"chart_deleted_successfully": "Grafik başarıyla silindi",
"chart_deletion_error": "Grafik silinemedi",
+ "chart_display_settings": "Grafik görüntüleme ayarları",
"chart_duplicated_successfully": "Grafik başarıyla çoğaltıldı",
"chart_duplication_error": "Grafik çoğaltılamadı",
"chart_name": "Grafik Adı",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "Bu grafiği silmek istediğinden emin misin?",
"dimensions": "Boyutlar",
"dimensions_toggle_description": "Verileri duygu durumu, soru türü ve diğer boyutlara göre grupla.",
+ "distribution_segment_label": "{label}: {value} ({percent})",
+ "distribution_value_share": "{value} ({percent})",
"edit_chart_description": "Grafik yapılandırmanı görüntüle ve düzenle.",
"edit_chart_title": "Grafiği Düzenle",
"edit_chart_title_named": "\"{name}\" Düzenle",
@@ -1784,6 +1787,7 @@
"group_by": "Grupla",
"group_by_description": "Verilerini bir veya daha fazla boyuta göre ayır (sıralama önemli).",
"group_data": "Verileri grupla",
+ "horizontal_bars": "Yatay çubuklar",
"is_not_set": "ayarlanmamış",
"is_set": "ayarlanmış",
"language_value_unspecified": "Belirtilmemiş",
@@ -1813,6 +1817,9 @@
"open_options": "Grafik seçeneklerini aç",
"or_filter_logic": "VEYA",
"original": "Orijinal",
+ "pie_display": "Görünüm",
+ "pie_display_breakdown": "Dağılım çubukları",
+ "pie_display_pie": "Pasta grafik",
"please_enter_chart_name": "Lütfen bir grafik adı gir",
"please_select_dashboard": "Lütfen bir kontrol paneli seç",
"predefined_measures": "Önceden Tanımlanmış Ölçümler",
@@ -1840,7 +1847,8 @@
"start_date": "Başlangıç tarihi",
"time_dimension": "Zaman Boyutu",
"time_dimension_title": "Zaman tabanlı gruplama ekle",
- "time_dimension_toggle_description": "Zaman içindeki eğilimleri izle."
+ "time_dimension_toggle_description": "Zaman içindeki eğilimleri izle.",
+ "vertical_bars": "Dikey çubuklar"
},
"dashboards": {
"add_count_charts": "{count} grafik ekle",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "Bu anket ve tüm yanıtları 30 gün sonra kalıcı olarak silinecek.",
"archiving_survey": "Anket arşivleniyor...",
"change_status": "Durumu değiştir",
+ "completed_responses": "Tamamlandı",
"copy_survey": "Anketi kopyala",
"copy_survey_description": "Bu anketi kopyalamak için bir çalışma alanı seç.",
"copy_survey_error": "Anket kopyalanamadı",
@@ -3953,7 +3962,6 @@
"allowed_values": "İzin verilen değerler: {values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "Yönetim API'sini kullanarak geri bildirim kayıtları oluştur",
- "api_ingestion_setup_description": "Geri bildirim kayıtlarını doğrudan Formbricks'e göndermek için REST API'sini kullanın. API entegrasyon dokümanları, endpoint, payload yapısı ve kimlik doğrulama detaylarını içerir.",
"auto_generated": "Otomatik olarak oluşturuldu",
"change_file": "Dosyayı değiştir",
"clear_mapping": "Eşleştirmeyi temizle",
diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json
index 0a24eaef1d09..0d225d1b5618 100644
--- a/apps/web/locales/zh-Hans-CN.json
+++ b/apps/web/locales/zh-Hans-CN.json
@@ -217,7 +217,6 @@
"code": "代码",
"collapse_rows": "折叠 行",
"column_n": "第 {n} 列",
- "coming_soon": "Coming soon",
"completed": "完成",
"confirm": "确认",
"connect": "连接",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "已在仪表板上",
"and_filter_logic": "且",
"apply_changes": "应用更改",
+ "bar_direction": "条形图方向",
"chart": "图表",
"chart_added_to_dashboard": "图表已添加到 Dashboard!",
"chart_data": "图表数据",
"chart_data_tab": "数据",
"chart_deleted_successfully": "图表删除成功",
"chart_deletion_error": "图表删除失败",
+ "chart_display_settings": "图表显示设置",
"chart_duplicated_successfully": "图表复制成功",
"chart_duplication_error": "图表复制失败",
"chart_name": "图表名称",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "你确定要删除这个图表吗?",
"dimensions": "维度",
"dimensions_toggle_description": "按情感、问题类型和其他维度对数据进行分组。",
+ "distribution_segment_label": "{label}:{value}({percent})",
+ "distribution_value_share": "{value}({percent})",
"edit_chart_description": "查看并编辑你的图表配置。",
"edit_chart_title": "编辑图表",
"edit_chart_title_named": "编辑“{name}”",
@@ -1784,6 +1787,7 @@
"group_by": "分组依据",
"group_by_description": "按一个或多个维度细分你的数据(顺序很重要)。",
"group_data": "分组数据",
+ "horizontal_bars": "横向条形图",
"is_not_set": "未设置",
"is_set": "已设置",
"language_value_unspecified": "未指定",
@@ -1813,6 +1817,9 @@
"open_options": "打开图表选项",
"or_filter_logic": "或",
"original": "原始",
+ "pie_display": "显示为",
+ "pie_display_breakdown": "细分条形图",
+ "pie_display_pie": "饼图",
"please_enter_chart_name": "请输入图表名称",
"please_select_dashboard": "请选择一个 Dashboard",
"predefined_measures": "预设度量",
@@ -1840,7 +1847,8 @@
"start_date": "开始日期",
"time_dimension": "时间维度",
"time_dimension_title": "添加基于时间的分组",
- "time_dimension_toggle_description": "监控随时间变化的趋势。"
+ "time_dimension_toggle_description": "监控随时间变化的趋势。",
+ "vertical_bars": "纵向条形图"
},
"dashboards": {
"add_count_charts": "添加 {count} 个图表",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "此调查问卷及其所有回复将在 30 天后永久删除。",
"archiving_survey": "正在归档问卷...",
"change_status": "更改状态",
+ "completed_responses": "已完成",
"copy_survey": "复制问卷",
"copy_survey_description": "选择要将此问卷复制到的工作区。",
"copy_survey_error": "复制问卷失败",
@@ -3953,7 +3962,6 @@
"allowed_values": "允许的值:{values}",
"api_ingestion": "API ingestion",
"api_ingestion_settings_description": "使用管理 API 创建反馈记录",
- "api_ingestion_setup_description": "使用 REST API 直接将反馈记录发送到 Formbricks。API 接入文档包含端点、请求体结构和身份验证详情。",
"auto_generated": "自动生成",
"change_file": "更换文件",
"clear_mapping": "清除映射",
diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json
index 5d93866bb6de..5ec514c993bb 100644
--- a/apps/web/locales/zh-Hant-TW.json
+++ b/apps/web/locales/zh-Hant-TW.json
@@ -217,7 +217,6 @@
"code": "程式碼",
"collapse_rows": "摺疊列",
"column_n": "欄 {n}",
- "coming_soon": "Coming soon",
"completed": "已完成",
"confirm": "確認",
"connect": "連線",
@@ -1647,12 +1646,14 @@
"already_on_dashboard": "已在儀表板上",
"and_filter_logic": "且",
"apply_changes": "套用變更",
+ "bar_direction": "長條方向",
"chart": "圖表",
"chart_added_to_dashboard": "圖表已新增到儀表板!",
"chart_data": "圖表資料",
"chart_data_tab": "資料",
"chart_deleted_successfully": "圖表已成功刪除",
"chart_deletion_error": "刪除圖表失敗",
+ "chart_display_settings": "圖表顯示設定",
"chart_duplicated_successfully": "圖表已成功複製",
"chart_duplication_error": "圖表複製失敗",
"chart_name": "圖表名稱",
@@ -1694,6 +1695,8 @@
"delete_chart_confirmation": "你確定要刪除此圖表嗎?",
"dimensions": "維度",
"dimensions_toggle_description": "依情感、問題類型及其他維度分組資料。",
+ "distribution_segment_label": "{label}:{value}({percent})",
+ "distribution_value_share": "{value}({percent})",
"edit_chart_description": "檢視並編輯你的圖表設定。",
"edit_chart_title": "編輯圖表",
"edit_chart_title_named": "編輯「{name}」",
@@ -1784,6 +1787,7 @@
"group_by": "分組依據",
"group_by_description": "依一個或多個維度細分你的資料(順序很重要)。",
"group_data": "分組資料",
+ "horizontal_bars": "水平長條",
"is_not_set": "未設定",
"is_set": "已設定",
"language_value_unspecified": "未指定",
@@ -1813,6 +1817,9 @@
"open_options": "開啟圖表選項",
"or_filter_logic": "或",
"original": "原始",
+ "pie_display": "顯示為",
+ "pie_display_breakdown": "細分長條圖",
+ "pie_display_pie": "圓餅圖",
"please_enter_chart_name": "請輸入圖表名稱",
"please_select_dashboard": "請選擇一個儀表板",
"predefined_measures": "預設指標",
@@ -1840,7 +1847,8 @@
"start_date": "開始日期",
"time_dimension": "時間維度",
"time_dimension_title": "新增基於時間的分組",
- "time_dimension_toggle_description": "監控隨時間變化的趨勢。"
+ "time_dimension_toggle_description": "監控隨時間變化的趨勢。",
+ "vertical_bars": "垂直長條"
},
"dashboards": {
"add_count_charts": "新增 {count} 個圖表",
@@ -2995,6 +3003,7 @@
"archive_survey_warning": "這份問卷及其所有回覆將在 30 天後永久刪除。",
"archiving_survey": "正在封存問卷...",
"change_status": "變更狀態",
+ "completed_responses": "已完成",
"copy_survey": "複製問卷",
"copy_survey_description": "選擇要將此問卷複製到的工作區。",
"copy_survey_error": "複製問卷失敗",
@@ -3953,7 +3962,6 @@
"allowed_values": "允許的值:{values}",
"api_ingestion": "API 擷取",
"api_ingestion_settings_description": "使用管理 API 建立意見回饋記錄",
- "api_ingestion_setup_description": "使用 REST API 直接將意見回饋記錄傳送至 Formbricks。API 擷取文件包含端點、負載格式及驗證詳情。",
"auto_generated": "自動生成",
"change_file": "更換檔案",
"clear_mapping": "清除對應",
diff --git a/apps/web/modules/auth/lib/auth-client.ts b/apps/web/modules/auth/lib/auth-client.ts
index 24a863a95e11..7f5fcf425608 100644
--- a/apps/web/modules/auth/lib/auth-client.ts
+++ b/apps/web/modules/auth/lib/auth-client.ts
@@ -1,14 +1,18 @@
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
-import { genericOAuthClient, twoFactorClient } from "better-auth/client/plugins";
+import { twoFactorClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
/**
* Better Auth client (ENG-1054). Same-origin, so no baseURL is needed. This is the ONLY
* auth module a `"use client"` component should import. Client plugins must mirror the server
- * plugins in auth.ts (genericOAuth providers are added in Phase 5).
+ * plugins in auth.ts.
+ *
+ * genericOAuth has no client plugin from Better Auth 1.7 (ENG-2343): it was rebuilt onto the
+ * built-in social provider path, so `signIn.social({ provider })` drives Azure/OIDC/SAML too and
+ * `signIn.oauth2` no longer exists.
*/
export const authClient = createAuthClient({
- plugins: [twoFactorClient(), genericOAuthClient(), oauthProviderClient()],
+ plugins: [twoFactorClient(), oauthProviderClient()],
});
export const { signIn, signUp, signOut, useSession } = authClient;
diff --git a/apps/web/modules/auth/lib/auth-two-factor.integration.test.ts b/apps/web/modules/auth/lib/auth-two-factor.integration.test.ts
index 47012fc4cdf5..5ece6e5b841c 100644
--- a/apps/web/modules/auth/lib/auth-two-factor.integration.test.ts
+++ b/apps/web/modules/auth/lib/auth-two-factor.integration.test.ts
@@ -37,15 +37,32 @@ beforeEach(async () => {
await resetDb();
});
+/**
+ * Better Auth 1.7 types `enableTwoFactor`'s response as a union on `method` — `{ method: "otp" }` carries
+ * no `totpURI` at all — so destructuring it directly no longer typechecks (surfaced once test files
+ * entered the typecheck graph, #8890).
+ *
+ * Narrowed rather than cast, deliberately: our config enrols TOTP, and if a future version ever answers
+ * `otp` here this fails with a legible message instead of feeding `undefined` into `secretFromUri` and
+ * failing several lines later as an unreadable TOTP error.
+ */
+const enrolTotp = async (cookie: string): Promise => {
+ const enrolled = await auth.api.enableTwoFactor({
+ body: { password: "Passw0rd!" },
+ headers: { cookie },
+ });
+ if (enrolled.method !== "totp") {
+ throw new Error(`expected a TOTP enrolment, got method="${enrolled.method}"`);
+ }
+ return enrolled.totpURI;
+};
+
describe("Better Auth two-factor (real Postgres)", () => {
test("enabling 2FA + verifying a TOTP flips twoFactorEnabled and stores the secret", async () => {
const userId = await createVerifiedUser("tfa@example.com", "Passw0rd!");
const cookie = await sessionCookie("tfa@example.com", "Passw0rd!");
- const { totpURI } = await auth.api.enableTwoFactor({
- body: { password: "Passw0rd!" },
- headers: { cookie },
- });
+ const totpURI = await enrolTotp(cookie);
expect(totpURI).toContain("otpauth://");
await auth.api.verifyTOTP({ body: { code: totp(secretFromUri(totpURI)) }, headers: { cookie } });
@@ -58,10 +75,7 @@ describe("Better Auth two-factor (real Postgres)", () => {
test("an enabled second factor gates sign-in: password yields a challenge, TOTP issues the session", async () => {
await createVerifiedUser("login2fa@example.com", "Passw0rd!");
const enrollCookie = await sessionCookie("login2fa@example.com", "Passw0rd!");
- const { totpURI } = await auth.api.enableTwoFactor({
- body: { password: "Passw0rd!" },
- headers: { cookie: enrollCookie },
- });
+ const totpURI = await enrolTotp(enrollCookie);
const secret = secretFromUri(totpURI);
await auth.api.verifyTOTP({ body: { code: totp(secret) }, headers: { cookie: enrollCookie } });
await prisma.session.deleteMany(); // clear the enrollment session
diff --git a/apps/web/modules/auth/lib/better-auth-observability.integration.test.ts b/apps/web/modules/auth/lib/better-auth-observability.integration.test.ts
index 7c76baffcc6f..3a8f52d36855 100644
--- a/apps/web/modules/auth/lib/better-auth-observability.integration.test.ts
+++ b/apps/web/modules/auth/lib/better-auth-observability.integration.test.ts
@@ -1,3 +1,4 @@
+import { createLocalAccountIssuer } from "@better-auth/core/db";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { resetDb } from "@/integration/reset-db";
@@ -46,6 +47,9 @@ describe("Observability — signedIn audit on session creation (real Postgres)",
provider: "credential",
providerAccountId: user.id,
password: user.password!,
+ // Represents an already-migrated existing account (ENG-2343); sign-in's findCredentialAccount
+ // filters on this.
+ issuer: createLocalAccountIssuer("credential"),
},
});
@@ -85,6 +89,9 @@ describe("Observability — failed-login audit on a rejected sign-in (real Postg
provider: "credential",
providerAccountId: user.id,
password: user.password!,
+ // Represents an already-migrated existing account (ENG-2343); sign-in's findCredentialAccount
+ // filters on this.
+ issuer: createLocalAccountIssuer("credential"),
},
});
diff --git a/apps/web/modules/auth/lib/better-auth-observability.test.ts b/apps/web/modules/auth/lib/better-auth-observability.test.ts
index f900a28101e1..61ee954fdfb9 100644
--- a/apps/web/modules/auth/lib/better-auth-observability.test.ts
+++ b/apps/web/modules/auth/lib/better-auth-observability.test.ts
@@ -89,7 +89,7 @@ describe("getSignInAuthMethod (signedIn audit allow-list)", () => {
["/two-factor/verify-totp", "password"],
["/two-factor/verify-backup-code", "password"],
["/callback/google", "sso"],
- ["/oauth2/callback/azuread", "sso"],
+ ["/callback/azuread", "sso"],
])("audits sign-in completion %s as %s", (path, expected) => {
expect(getSignInAuthMethod(path)).toBe(expected);
});
diff --git a/apps/web/modules/auth/lib/better-auth-observability.ts b/apps/web/modules/auth/lib/better-auth-observability.ts
index cc5c5d2ce8fd..c7eee7c35143 100644
--- a/apps/web/modules/auth/lib/better-auth-observability.ts
+++ b/apps/web/modules/auth/lib/better-auth-observability.ts
@@ -35,7 +35,11 @@ import { logAuthAttempt, shouldLogAuthFailure } from "./utils";
*/
export const getSignInAuthMethod = (path: string | undefined): string | null => {
if (!path) return null;
- // /callback/:id (social) and /oauth2/callback/:providerId (generic OAuth/SAML) both contain /callback/
+ // /callback/:id covers both built-in social and, since Better Auth 1.7 (ENG-2343), genericOAuth/SAML
+ // too — they share the social-provider route now instead of the old /oauth2/callback/:providerId.
+ // This is the INTERNAL endpoint path, which is why it is not the pinned public callback URL: the URL
+ // customers register stays /api/auth/oauth2/callback/{providerId}, and legacy-sso-callback.ts maps it
+ // onto this route before Better Auth sees it. Do not "restore" /oauth2/ here.
if (path.includes("/callback/")) return "sso";
if (path === "/sign-in/email") return "password";
// Auto-login after email verification (autoSignInAfterVerification, ENG-1746) creates a session for
diff --git a/apps/web/modules/auth/lib/better-auth-path-label.ts b/apps/web/modules/auth/lib/better-auth-path-label.ts
index 2169c160b92d..f0a9701667bd 100644
--- a/apps/web/modules/auth/lib/better-auth-path-label.ts
+++ b/apps/web/modules/auth/lib/better-auth-path-label.ts
@@ -32,8 +32,9 @@ import "server-only";
// Deliberately a local copy of the literal in oauth-urls.ts rather than an import: that module reads
// `@/lib/env`, and pulling env validation into this one would cost it the property that makes it
-// exhaustively testable — no dependencies, no environment. Both sites are grep-findable as
-// "/api/auth" if the base path ever becomes configurable (ENG-606).
+// exhaustively testable — no dependencies, no environment. All three sites — this one, oauth-urls.ts and
+// legacy-sso-callback.ts — are grep-findable as "/api/auth" if the base path ever becomes
+// configurable (ENG-606).
const AUTH_BASE_PATH = "/api/auth";
/** Emitted when the URL is unparseable or names no endpoint we serve. Bounds tag cardinality. */
diff --git a/apps/web/modules/auth/lib/better-auth-redirect-uri-pin.test.ts b/apps/web/modules/auth/lib/better-auth-redirect-uri-pin.test.ts
new file mode 100644
index 000000000000..7a1d75212643
--- /dev/null
+++ b/apps/web/modules/auth/lib/better-auth-redirect-uri-pin.test.ts
@@ -0,0 +1,162 @@
+import { betterAuth } from "better-auth";
+import { memoryAdapter } from "better-auth/adapters/memory";
+import { genericOAuth } from "better-auth/plugins";
+import { afterEach, describe, expect, test, vi } from "vitest";
+
+/**
+ * The upgrade guard for the pinned SSO callback URL (ENG-2343).
+ *
+ * Our three generic-OAuth providers set `redirectURI` so the callback URL stops tracking Better Auth's
+ * routing — it has already moved twice (1.6's genericOAuth plugin mounted `/oauth2/callback/:providerId`;
+ * 1.7 rebuilt the plugin onto the built-in `/callback/:id`), and each move otherwise forces every
+ * self-hoster to re-register a redirect URI at their IdP, which OAuth requires to match exactly.
+ *
+ * That pin rests on an upstream option we do not control. Both the authorization request and the token
+ * exchange resolve it as `options.redirectURI || redirectURI`, so if a future release drops or reorders
+ * that precedence, Better Auth silently starts advertising its own default path again and every SSO
+ * sign-in fails at the IdP with a redirect-URI mismatch — in production, on upgrade, with nothing in our
+ * own diff to explain it. This asserts the behaviour against a REAL Better Auth instance so the failure
+ * lands here instead.
+ *
+ * Deliberately not a unit test of our config (better-auth-providers.test.ts covers that) and deliberately
+ * network-free: the provider is configured with explicit endpoint URLs rather than `discoveryUrl`, which
+ * is the same shape the SAML bridge provider uses in production.
+ */
+
+const BASE_URL = "https://app.formbricks.test";
+const PINNED_REDIRECT_URI = `${BASE_URL}/api/auth/oauth2/callback/pinned-provider`;
+const IDP = "https://idp.formbricks.test";
+
+const createAuthInstance = () =>
+ betterAuth({
+ baseURL: BASE_URL,
+ secret: "better-auth-redirect-uri-pin-test-secret",
+ // memoryAdapter does not create models lazily, so every model the sign-in touches is declared here.
+ database: memoryAdapter({ user: [], session: [], account: [], verification: [] }),
+ plugins: [
+ genericOAuth({
+ config: [
+ {
+ providerId: "pinned-provider",
+ clientId: "pinned-client",
+ clientSecret: "pinned-secret",
+ authorizationUrl: `${IDP}/authorize`,
+ tokenUrl: `${IDP}/token`,
+ userInfoUrl: `${IDP}/userinfo`,
+ scopes: ["openid", "email", "profile"],
+ pkce: true,
+ redirectURI: PINNED_REDIRECT_URI,
+ },
+ ],
+ }),
+ ],
+ });
+
+const getAuthorizationUrl = async (): Promise => {
+ const auth = createAuthInstance();
+ // `signInSocial`, not a genericOAuth endpoint: in 1.7 the plugin registers no routes of its own, it
+ // only appends its providers into `ctx.socialProviders`. That is the whole reason the callback path
+ // moved, so driving the core endpoint is what exercises the real production path.
+ const response = await auth.api.signInSocial({
+ body: { provider: "pinned-provider", callbackURL: "/" },
+ });
+ return new URL((response as { url: string }).url);
+};
+
+describe("Better Auth honours the pinned SSO redirect URI", () => {
+ test("sends our redirectURI to the IdP rather than its own callback path", async () => {
+ const authorizationUrl = await getAuthorizationUrl();
+
+ expect(authorizationUrl.origin + authorizationUrl.pathname).toBe(`${IDP}/authorize`);
+ expect(authorizationUrl.searchParams.get("redirect_uri")).toBe(PINNED_REDIRECT_URI);
+ });
+
+ /**
+ * The specific regression to catch. Better Auth's own default is `/api/auth/callback/{providerId}`,
+ * which is what a dropped `redirectURI` would fall back to — asserting the absence of that string is
+ * what distinguishes "the option was honoured" from "the option happened to match the default".
+ */
+ test("never falls back to the version default callback path", async () => {
+ const authorizationUrl = await getAuthorizationUrl();
+ const redirectUri = authorizationUrl.searchParams.get("redirect_uri") ?? "";
+
+ expect(redirectUri).toContain("/api/auth/oauth2/callback/");
+ expect(redirectUri).not.toBe(`${BASE_URL}/api/auth/callback/pinned-provider`);
+ });
+});
+
+/**
+ * The other half of the pin, and the one that fails in production if it regresses.
+ *
+ * `options.redirectURI || redirectURI` is resolved TWICE by upstream — once building the authorization
+ * URL (`create-authorization-url.mjs`) and once building the token request
+ * (`validate-authorization-code.mjs`). The tests above only drive the first. If a release kept the option
+ * on the authorization leg and dropped it on the token leg, they would all stay green while every SSO
+ * sign-in died at the identity provider's token endpoint with a `redirect_uri` mismatch — the two legs
+ * MUST send the same value, and that is what this asserts.
+ *
+ * Driven as a real two-leg flow against one instance so the `state` verification row and its signed
+ * cookie are the genuine ones: sign-in through `auth.handler` to get the state + cookie, then the
+ * callback with both, with `fetch` stubbed at the IdP boundary to capture what was posted.
+ */
+describe("Better Auth sends the pinned redirect URI on the token leg too", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ test("the token request carries the same redirect_uri as the authorization request", async () => {
+ const auth = createAuthInstance();
+
+ const signIn = await auth.handler(
+ new Request(`${BASE_URL}/api/auth/sign-in/social`, {
+ method: "POST",
+ headers: { "content-type": "application/json", origin: BASE_URL },
+ body: JSON.stringify({ provider: "pinned-provider", callbackURL: "/" }),
+ })
+ );
+ expect(signIn.status).toBe(200);
+
+ const { url: authorizationUrl } = (await signIn.json()) as { url: string };
+ const authorizationRedirectUri = new URL(authorizationUrl).searchParams.get("redirect_uri");
+ const state = new URL(authorizationUrl).searchParams.get("state") ?? "";
+ expect(state).not.toBe("");
+
+ // The signed state cookie Better Auth just issued; the callback rejects the state without it.
+ const cookie = (signIn.headers.getSetCookie?.() ?? []).map((value) => value.split(";")[0]).join("; ");
+ expect(cookie).not.toBe("");
+
+ let tokenRedirectUri: string | null = null;
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
+ const requested = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (requested.startsWith(`${IDP}/token`)) {
+ // `redirect_uri` is form-encoded in the token request body — the value under test.
+ tokenRedirectUri = new URLSearchParams(String(init?.body ?? "")).get("redirect_uri");
+ return Response.json({
+ access_token: "pinned-access-token",
+ token_type: "Bearer",
+ expires_in: 3600,
+ scope: "openid email profile",
+ });
+ }
+ if (requested.startsWith(`${IDP}/userinfo`)) {
+ return Response.json({
+ sub: "pinned-subject",
+ email: "pinned@formbricks.test",
+ email_verified: true,
+ name: "Pinned Person",
+ });
+ }
+ throw new Error(`unexpected outbound fetch: ${requested}`);
+ });
+
+ await auth.handler(
+ new Request(`${BASE_URL}/api/auth/callback/pinned-provider?code=pinned-code&state=${state}`, {
+ headers: { cookie },
+ })
+ );
+
+ expect(tokenRedirectUri).toBe(PINNED_REDIRECT_URI);
+ // Both legs agree, which is the property the pin depends on.
+ expect(tokenRedirectUri).toBe(authorizationRedirectUri);
+ });
+});
diff --git a/apps/web/modules/auth/lib/better-auth-schema-contract.test.ts b/apps/web/modules/auth/lib/better-auth-schema-contract.test.ts
new file mode 100644
index 000000000000..60fed4b83bcf
--- /dev/null
+++ b/apps/web/modules/auth/lib/better-auth-schema-contract.test.ts
@@ -0,0 +1,181 @@
+import { getAuthTables } from "@better-auth/core/db";
+import { oauthProvider } from "@better-auth/oauth-provider";
+import { jwt } from "better-auth/plugins/jwt";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, test } from "vitest";
+
+/**
+ * The schema contract between Better Auth's plugins and our Prisma models (ENG-2343).
+ *
+ * Better Auth owns these tables: each plugin declares the fields, and Better Auth writes rows through
+ * whichever adapter is configured — for us, Prisma. So a field a plugin declares and our model lacks is
+ * not a cosmetic mismatch, it is a failed INSERT: Prisma rejects the unknown argument at runtime.
+ *
+ * This class of drift nearly shipped. `jwks.alg` and `jwks.crv` arrived with the 1.7 line and our model
+ * did not have them, and NOTHING in the suite noticed, because nothing here writes a real row to these
+ * tables: the unit suites mock `@formbricks/database` wholesale, the MCP DCR harness runs on
+ * `memoryAdapter` (which does not enforce columns), and no integration test mints a JWK. It surfaces
+ * only against a real database — for `jwks`, on the first key mint of a deployment that has yet to make
+ * one, which takes JWT signing and the whole MCP OAuth flow down with it.
+ *
+ * So these read each plugin's own declaration rather than restating a field list, and the next field
+ * upstream adds fails at `pnpm test` instead of in production.
+ *
+ * The same drift is possible on the CORE tables, and there it has already bitten us twice: 1.7 keys
+ * accounts on `(issuer, accountId)`, and `Account.issuer` is a core field no plugin declares — so the
+ * plugin-only pass below would not have caught a missing column. `getAuthTables` is upstream's own
+ * resolver for the merged core+plugin schema, so the core assertions track whatever the installed
+ * version declares, exactly like the plugin ones.
+ *
+ * Scope note: this checks that our model is a SUPERSET of what the plugin declares, which is the
+ * direction that breaks writes. Extra columns of our own are fine and expected — `oauthClient` keeps
+ * the legacy `public`/`type` for the rollback path, and every model carries an `id` and Prisma relation
+ * fields the plugin never declares.
+ */
+const prismaSchemaPath = join(
+ dirname(fileURLToPath(import.meta.url)),
+ "../../../../../packages/database/schema/main.prisma"
+);
+
+/** Field names on a Prisma model, skipping comments, block attributes and the braces. */
+const prismaModelFields = (source: string, model: string): string[] => {
+ const block = new RegExp(`^model ${model} \\{$([\\s\\S]*?)^\\}$`, "m").exec(source);
+ if (!block) throw new Error(`model ${model} not found in main.prisma`);
+
+ return block[1]
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line !== "" && !line.startsWith("//") && !line.startsWith("@@"))
+ .map((line) => line.split(/\s+/)[0]);
+};
+
+const prismaSchema = readFileSync(prismaSchemaPath, "utf8");
+
+/**
+ * Both plugins expose their merged schema on the instance, so this tracks whatever the installed
+ * version declares. `oauthProvider` needs its two mandatory options to construct.
+ */
+const declaredModels = {
+ ...jwt().schema,
+ ...oauthProvider({ loginPage: "/auth/login", consentPage: "/account/authorize" }).schema,
+} as Record }>;
+
+/**
+ * The core-table field mapping from `auth.ts`, mirrored rather than imported: importing `auth.ts` would
+ * construct the real instance and pull in env, Prisma and Redis, which a schema-shape unit test has no
+ * business booting. The mirror is guarded against the source below, so it cannot rot silently.
+ */
+const CORE_FIELD_MAPPING = {
+ session: { token: "sessionToken", expiresAt: "expires" },
+ account: {
+ providerId: "provider",
+ accountId: "providerAccountId",
+ accessToken: "access_token",
+ refreshToken: "refresh_token",
+ idToken: "id_token",
+ },
+} as const;
+
+const coreTables = getAuthTables({
+ session: { fields: { ...CORE_FIELD_MAPPING.session } },
+ account: { fields: { ...CORE_FIELD_MAPPING.account } },
+});
+
+/**
+ * Core fields Better Auth declares that we deliberately do not persist, with the mitigation that makes
+ * that safe. An entry here is only defensible while its mitigation is in place, so each one is asserted
+ * below rather than merely allowed — an exclusion nobody re-checks is how a declared field becomes a
+ * failing INSERT.
+ */
+const CORE_FIELDS_NOT_PERSISTED = {
+ // `User.imageUrl` was dropped in 20250813071701_remove_user_image_url. Better Auth still maps a
+ // provider image (Google picture / GitHub avatar / OIDC picture), and the SSO user-create hook
+ // strips it to `undefined` so `transformInput` drops it before Prisma sees it.
+ user: { image: { file: "../../ee/sso/lib/better-auth-hooks.ts", strips: "image: undefined" } },
+} as const;
+
+/** BA's model keys are lower-case; our Prisma models are PascalCase. */
+const prismaModelName = (model: string): string => model.charAt(0).toUpperCase() + model.slice(1);
+
+describe("Better Auth ↔ Prisma schema contract", () => {
+ describe("core tables", () => {
+ // Guard the mirror: a mapping changed in auth.ts and not here would make the assertions below
+ // compare BA's canonical names against columns we never named that way — and pass for the wrong
+ // reason on any field that happens to be absent from both sides.
+ test.each(
+ Object.entries(CORE_FIELD_MAPPING).flatMap(([model, fields]) =>
+ Object.entries(fields).map(([logical, column]) => [model, logical, column])
+ )
+ )("auth.ts still maps %s.%s onto %s", (_model, logical, column) => {
+ const authSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "auth.ts"), "utf8");
+
+ expect(authSource).toContain(`${logical}: "${column}"`);
+ });
+
+ // `verification` is deliberately absent: with Redis `secondaryStorage` configured Better Auth keeps
+ // verification records there and never touches a table, so there is no Prisma model to check
+ // (auth.ts:70-72). `session` IS checked, because `storeSessionInDatabase` opts it back into the DB.
+ test.each(["user", "session", "account"])(
+ "our Prisma model for %s carries every core field Better Auth declares",
+ (model) => {
+ const notPersisted = Object.keys(
+ CORE_FIELDS_NOT_PERSISTED[model as keyof typeof CORE_FIELDS_NOT_PERSISTED] ?? {}
+ );
+ const declared = Object.entries(coreTables[model].fields)
+ .filter(([name]) => !notPersisted.includes(name))
+ .map(([name, attribute]) => attribute.fieldName ?? name);
+ const ours = prismaModelFields(prismaSchema, prismaModelName(model));
+
+ expect(declared.filter((field) => !ours.includes(field))).toEqual([]);
+ }
+ );
+
+ // Bind each exclusion above to the code that makes it safe: if the strip is removed, the field is
+ // back to being a missing column and the exclusion has to go with it.
+ test.each(
+ Object.entries(CORE_FIELDS_NOT_PERSISTED).flatMap(([model, fields]) =>
+ Object.entries(fields).map(([field, { file, strips }]) => [model, field, file, strips])
+ )
+ )("%s.%s is still stripped before the insert", (_model, _field, file, strips) => {
+ const source = readFileSync(join(dirname(fileURLToPath(import.meta.url)), file), "utf8");
+
+ expect(source).toContain(strips);
+ });
+
+ // The field this whole file exists for: 1.7 filters account lookups on it, and it is ours to keep.
+ test("account.issuer is declared by Better Auth and present on our model", () => {
+ expect(Object.keys(coreTables.account.fields)).toContain("issuer");
+ expect(prismaModelFields(prismaSchema, "Account")).toContain("issuer");
+ });
+ });
+
+ // Guard the guard: if either plugin stops exposing an introspectable schema, the per-model assertions
+ // below would silently pass against empty field lists and prove nothing.
+ test("both plugins expose the models we own", () => {
+ expect(Object.keys(declaredModels).sort()).toEqual([
+ "jwks",
+ "oauthAccessToken",
+ "oauthClient",
+ "oauthClientAssertion",
+ "oauthClientResource",
+ "oauthConsent",
+ "oauthRefreshToken",
+ "oauthResource",
+ ]);
+ expect(Object.keys(declaredModels.jwks.fields ?? {})).toEqual(
+ expect.arrayContaining(["publicKey", "privateKey", "createdAt", "alg", "crv"])
+ );
+ });
+
+ test.each(Object.keys(declaredModels).sort())(
+ "our Prisma model %s carries every field its plugin declares",
+ (model) => {
+ const declared = Object.keys(declaredModels[model].fields ?? {});
+ const ours = prismaModelFields(prismaSchema, model);
+
+ expect(declared.filter((field) => !ours.includes(field))).toEqual([]);
+ }
+ );
+});
diff --git a/apps/web/modules/auth/lib/cutover/reencode-two-factor.integration.test.ts b/apps/web/modules/auth/lib/cutover/reencode-two-factor.integration.test.ts
index a8de5269c859..ca60b0ccd825 100644
--- a/apps/web/modules/auth/lib/cutover/reencode-two-factor.integration.test.ts
+++ b/apps/web/modules/auth/lib/cutover/reencode-two-factor.integration.test.ts
@@ -1,3 +1,4 @@
+import { createLocalAccountIssuer } from "@better-auth/core/db";
import { authenticator } from "otplib";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { prisma } from "@formbricks/database";
@@ -60,6 +61,10 @@ describe("2FA secret re-encode (real Postgres)", () => {
provider: "credential",
providerAccountId: user.id,
password: user.password!,
+ // Represents an already-migrated existing account (ENG-2343): real rows carry this from either
+ // Better Auth's own sign-up path or the ENG-2343 backfill, and sign-in's findCredentialAccount
+ // filters on it.
+ issuer: createLocalAccountIssuer("credential"),
},
});
@@ -112,6 +117,10 @@ describe("2FA secret re-encode (real Postgres)", () => {
provider: "credential",
providerAccountId: user.id,
password: user.password!,
+ // Represents an already-migrated existing account (ENG-2343): real rows carry this from either
+ // Better Auth's own sign-up path or the ENG-2343 backfill, and sign-in's findCredentialAccount
+ // filters on it.
+ issuer: createLocalAccountIssuer("credential"),
},
});
@@ -194,6 +203,10 @@ describe("2FA secret re-encode (real Postgres)", () => {
provider: "credential",
providerAccountId: user.id,
password: user.password!,
+ // Represents an already-migrated existing account (ENG-2343): real rows carry this from either
+ // Better Auth's own sign-up path or the ENG-2343 backfill, and sign-in's findCredentialAccount
+ // filters on it.
+ issuer: createLocalAccountIssuer("credential"),
},
});
@@ -250,6 +263,10 @@ describe("2FA secret re-encode (real Postgres)", () => {
provider: "credential",
providerAccountId: user.id,
password: user.password!,
+ // Represents an already-migrated existing account (ENG-2343): real rows carry this from either
+ // Better Auth's own sign-up path or the ENG-2343 backfill, and sign-in's findCredentialAccount
+ // filters on it.
+ issuer: createLocalAccountIssuer("credential"),
},
});
diff --git a/apps/web/modules/auth/lib/legacy-sso-callback.integration.test.ts b/apps/web/modules/auth/lib/legacy-sso-callback.integration.test.ts
new file mode 100644
index 000000000000..e518701ba849
--- /dev/null
+++ b/apps/web/modules/auth/lib/legacy-sso-callback.integration.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, test, vi } from "vitest";
+import { GET } from "@/app/api/auth/[...all]/route";
+import { auth } from "@/modules/auth/lib/auth";
+import { runWithSsoRequestContext } from "@/modules/ee/sso/lib/sso-request-context";
+
+/**
+ * ENG-2343 at the ROUTER boundary: the pinned SSO callback URL against a real Better Auth instance.
+ *
+ * `redirectURI` makes Better Auth advertise `/api/auth/oauth2/callback/{providerId}` — the URL customer
+ * IdPs have had registered since v5.2 — while 1.7 mounts its handler at `/callback/:id`. The unit tests
+ * cover the mapper's string logic and that the route calls it, but both run against a MOCKED
+ * `auth.handler`, so neither can show that the real router accepts the mapped path. That is the half of
+ * the change that would fail in production: a path the router does not match is a 404 on every SSO
+ * sign-in, and no amount of mapper unit-testing would reveal it.
+ *
+ * The claim asserted is exactly the claim the change makes: a request at the pinned URL is handled
+ * *identically* to one at the path this version serves. Comparing the two responses rather than
+ * hardcoding an expected status is deliberate — it stays true across Better Auth versions and does not
+ * encode today's particular OAuth error, while still failing loudly if the mapping stops working.
+ *
+ * `saml` is the provider under test because it is the one pinned provider configured with explicit
+ * endpoint URLs rather than a `discoveryUrl`, so registering it pulls in no outbound network call. State
+ * validation runs before any token exchange in any case, so both requests fail at the same place.
+ */
+
+// Register the SAML generic provider: the config array is gated on ENTERPRISE_LICENSE_KEY, and the
+// provider itself on SAML_OAUTH_ENABLED. Without a registered provider the callback would answer
+// identically for both paths for the *wrong* reason (an unknown provider), and the comparison below
+// would pass while proving nothing.
+vi.mock("@/lib/constants", async (importOriginal) => ({
+ ...(await importOriginal()),
+ ENTERPRISE_LICENSE_KEY: "integration-license",
+ SAML_OAUTH_ENABLED: true,
+}));
+
+const BASE = "http://localhost:3000";
+const QUERY = "code=integration-code&state=integration-state";
+
+const summarize = async (response: Response) => ({
+ status: response.status,
+ location: response.headers.get("location"),
+});
+
+/** Through the mounted route, so the mapper is in the path — the production call. */
+const viaPinnedUrl = (path: string): Promise => GET(new Request(`${BASE}${path}?${QUERY}`));
+
+/** Straight at Better Auth, bypassing the mapper — the control. */
+const viaHandler = (path: string): Promise =>
+ runWithSsoRequestContext(() => auth.handler(new Request(`${BASE}${path}?${QUERY}`)));
+
+describe("pinned SSO callback URL reaches Better Auth's callback route", () => {
+ test("the pinned path is handled exactly as the path this version serves", async () => {
+ const [pinned, current] = await Promise.all([
+ viaPinnedUrl("/api/auth/oauth2/callback/saml").then(summarize),
+ viaHandler("/api/auth/callback/saml").then(summarize),
+ ]);
+
+ expect(pinned).toEqual(current);
+ });
+
+ /**
+ * The control that gives the assertion above its meaning: without the mapper the pinned path is a 404,
+ * because no Better Auth 1.7 route is mounted under `/oauth2/callback/`. An unpinned provider id takes
+ * exactly that route, so this pins down *why* the comparison passes.
+ */
+ test("an unpinned provider id is not mapped and 404s", async () => {
+ const response = await viaPinnedUrl("/api/auth/oauth2/callback/not-a-pinned-provider");
+
+ expect(response.status).toBe(404);
+ });
+
+ test("the pinned path is reached, not 404ed", async () => {
+ const response = await viaPinnedUrl("/api/auth/oauth2/callback/saml");
+
+ expect(response.status).not.toBe(404);
+ });
+});
diff --git a/apps/web/modules/auth/lib/legacy-sso-callback.test.ts b/apps/web/modules/auth/lib/legacy-sso-callback.test.ts
new file mode 100644
index 000000000000..b30404438fd7
--- /dev/null
+++ b/apps/web/modules/auth/lib/legacy-sso-callback.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, test } from "vitest";
+import {
+ PINNED_SSO_PROVIDER_IDS,
+ mapLegacySsoCallbackRequest,
+ mapLegacySsoCallbackUrl,
+} from "./legacy-sso-callback";
+
+const BASE = "https://app.formbricks.test";
+
+describe("mapLegacySsoCallbackUrl (ENG-2343)", () => {
+ test.each(PINNED_SSO_PROVIDER_IDS)("maps the pinned legacy callback for %s", (providerId) => {
+ expect(mapLegacySsoCallbackUrl(`${BASE}/api/auth/oauth2/callback/${providerId}`)).toBe(
+ `${BASE}/api/auth/callback/${providerId}`
+ );
+ });
+
+ // The query is the whole point of a callback — dropping it would strip `code`/`state` and turn every
+ // SSO sign-in into a silent failure that looks like an IdP problem.
+ test("carries the authorization code and state across untouched", () => {
+ const mapped = mapLegacySsoCallbackUrl(
+ `${BASE}/api/auth/oauth2/callback/openid?code=abc%2F123&state=xyz&iss=${encodeURIComponent(BASE)}`
+ );
+
+ const url = new URL(mapped ?? "");
+ expect(url.pathname).toBe("/api/auth/callback/openid");
+ expect(url.searchParams.get("code")).toBe("abc/123");
+ expect(url.searchParams.get("state")).toBe("xyz");
+ expect(url.searchParams.get("iss")).toBe(BASE);
+ });
+
+ // A Next.js basePath deployment serves the app from a subpath, so the auth segment is not at the root
+ // of the pathname. Same reasoning as better-auth-path-label.ts (see ENG-606).
+ test("resolves under a basePath deployment", () => {
+ expect(mapLegacySsoCallbackUrl(`${BASE}/custom-path/api/auth/oauth2/callback/saml`)).toBe(
+ `${BASE}/custom-path/api/auth/callback/saml`
+ );
+ });
+
+ // The crafted-prefix guard must not also reject a legitimate basePath that merely STARTS with the auth
+ // path — matching on `/api/auth` without the trailing slash would 404 SSO on such a deployment.
+ test("resolves under a basePath that starts with the auth path", () => {
+ expect(mapLegacySsoCallbackUrl(`${BASE}/api/authority/api/auth/oauth2/callback/openid`)).toBe(
+ `${BASE}/api/authority/api/auth/callback/openid`
+ );
+ });
+
+ /**
+ * The scoping that makes this safe to run in the `/api/auth/*` catch-all. The oauth-provider plugin
+ * owns roughly fifteen sibling `/oauth2/*` routes for our own MCP OAuth server; an unscoped prefix
+ * rewrite would shadow whichever one upstream adds next. Everything not an exact pinned provider id
+ * must pass through untouched.
+ */
+ test.each([
+ ["a sibling MCP OAuth route", `${BASE}/api/auth/oauth2/userinfo`],
+ ["the MCP consent route", `${BASE}/api/auth/oauth2/consent`],
+ ["the current-version callback", `${BASE}/api/auth/callback/openid`],
+ ["an unpinned provider id", `${BASE}/api/auth/oauth2/callback/google`],
+ ["a deeper path under a pinned id", `${BASE}/api/auth/oauth2/callback/openid/extra`],
+ // Rejected here as defence in depth only: Next.js 308-normalises a trailing slash (and doubled
+ // slashes) to the canonical path before the route handler runs, so in production this shape reaches
+ // the mapper already canonicalised and IS mapped. Verified against a running dev server.
+ ["a trailing slash", `${BASE}/api/auth/oauth2/callback/openid/`],
+ // Keeps the basePath tolerance from accepting a crafted double auth segment, so the only path this
+ // function can emit is `/api/auth/callback/`.
+ ["a second auth segment in the prefix", `${BASE}/api/auth/x/api/auth/oauth2/callback/openid`],
+ ["no provider id at all", `${BASE}/api/auth/oauth2/callback/`],
+ ["an unrelated endpoint", `${BASE}/api/auth/sign-in/email`],
+ ["a non-auth route", `${BASE}/api/v3/surveys`],
+ ["a percent-encoded provider id", `${BASE}/api/auth/oauth2/callback/openi%64`],
+ ["percent-encoded separators", `${BASE}/api/auth/oauth2%2fcallback%2fopenid`],
+ ["an upper-cased path", `${BASE}/api/auth/OAUTH2/CALLBACK/OPENID`],
+ ["an unparseable url", "not-a-url"],
+ // A cannot-be-a-base URL: the `pathname` setter is a no-op there, so without the protocol guard
+ // this would come back unchanged yet non-null — a non-rewrite reported as a rewrite.
+ ["an opaque, cannot-be-a-base url", "data:text/plain,/api/auth/oauth2/callback/openid"],
+ ])("leaves %s alone", (_label, url) => {
+ expect(mapLegacySsoCallbackUrl(url)).toBeNull();
+ });
+});
+
+describe("mapLegacySsoCallbackUrl — normalisation order (ENG-2343)", () => {
+ // `new URL()` resolves dot segments at construction, so matching runs on the normalised path. That is
+ // the safe order: a traversal cannot be smuggled past the match, it just canonicalises into it.
+ test.each([
+ `${BASE}/api/auth/oauth2/callback/../callback/openid`,
+ `${BASE}/api/auth/oauth2/callback/x/../openid`,
+ ])("normalises dot segments before matching: %s", (url) => {
+ expect(mapLegacySsoCallbackUrl(url)).toBe(`${BASE}/api/auth/callback/openid`);
+ });
+});
+
+describe("mapLegacySsoCallbackRequest (ENG-2343)", () => {
+ test("rewrites a GET callback and preserves method and headers", () => {
+ const request = new Request(`${BASE}/api/auth/oauth2/callback/azuread?code=abc`, {
+ headers: { cookie: "better-auth.state=s" },
+ });
+
+ const mapped = mapLegacySsoCallbackRequest(request);
+
+ expect(mapped.url).toBe(`${BASE}/api/auth/callback/azuread?code=abc`);
+ expect(mapped.method).toBe("GET");
+ // Carrying the cookie is load-bearing: Better Auth reads the state/PKCE cookie on the callback, so
+ // dropping it would fail the sign-in as a state mismatch.
+ expect(mapped.headers.get("cookie")).toBe("better-auth.state=s");
+ });
+
+ // An IdP configured for `response_mode=form_post` returns the code as a POST body.
+ test("forwards a POST body for a form_post response mode", async () => {
+ const request = new Request(`${BASE}/api/auth/oauth2/callback/azuread`, {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ body: "code=abc&state=xyz",
+ });
+
+ const mapped = mapLegacySsoCallbackRequest(request);
+
+ expect(mapped.url).toBe(`${BASE}/api/auth/callback/azuread`);
+ expect(mapped.method).toBe("POST");
+ await expect(mapped.text()).resolves.toBe("code=abc&state=xyz");
+ });
+
+ // Rebuilding a Request drops everything not copied. Behavioural rather than identity-based: the spec
+ // lets an implementation wrap the passed signal rather than reuse the object.
+ test("carries the abort signal so a client disconnect still cancels the handler", () => {
+ const controller = new AbortController();
+ const request = new Request(`${BASE}/api/auth/oauth2/callback/openid?code=abc`, {
+ signal: controller.signal,
+ });
+
+ const mapped = mapLegacySsoCallbackRequest(request);
+
+ expect(mapped.signal.aborted).toBe(false);
+ controller.abort();
+ expect(mapped.signal.aborted).toBe(true);
+ });
+
+ test("returns the original request untouched when the path is not a pinned callback", () => {
+ const request = new Request(`${BASE}/api/auth/sign-in/email`, { method: "POST" });
+
+ expect(mapLegacySsoCallbackRequest(request)).toBe(request);
+ });
+});
diff --git a/apps/web/modules/auth/lib/legacy-sso-callback.ts b/apps/web/modules/auth/lib/legacy-sso-callback.ts
new file mode 100644
index 000000000000..b6a951d601c5
--- /dev/null
+++ b/apps/web/modules/auth/lib/legacy-sso-callback.ts
@@ -0,0 +1,117 @@
+import "server-only";
+
+/**
+ * Keep serving the SSO callback URL that customer IdPs have had registered since v5.2, whatever path
+ * the installed Better Auth actually mounts its handler on (ENG-2343).
+ *
+ * Better Auth has moved this path twice, neither time by our choice: the 1.6 `genericOAuth` plugin
+ * mounted its own `/oauth2/callback/:providerId` route, and 1.7 rebuilt that plugin onto the built-in
+ * `/callback/:id` route. Each move otherwise forces every self-hoster to re-register a redirect URI at
+ * their IdP, which is the friction this module exists to end.
+ *
+ * The fix has two halves. `better-auth-providers.ts` pins `redirectURI` so Better Auth *advertises* the
+ * v5.2 URL — but that option does not move the route the handler is mounted on, and it is an upstream
+ * option we could lose. This half maps the advertised URL onto the path the installed version serves,
+ * and it is entirely ours: no upstream release can take it away. So if the pin ever stops working, SSO
+ * fails at the IdP with a `redirect_uri` mismatch (loud, and caught by
+ * better-auth-redirect-uri-pin.test.ts at upgrade time) rather than half-working.
+ *
+ * Deliberately dependency-free — no env, no license, no `auth` import — so it is exhaustively testable
+ * and so it works during the window where the provider list is empty (the generic providers are gated
+ * behind `ENTERPRISE_LICENSE_KEY`).
+ */
+
+const AUTH_BASE_PATH = "/api/auth";
+const LEGACY_CALLBACK_SEGMENT = `${AUTH_BASE_PATH}/oauth2/callback/`;
+const CURRENT_CALLBACK_SEGMENT = `${AUTH_BASE_PATH}/callback/`;
+
+/**
+ * The generic-OAuth providers whose `redirectURI` is pinned to the legacy path. Kept as a local literal
+ * rather than derived from `ssoGenericOAuthConfig`: that list is env- and license-gated and is empty on
+ * an unlicensed instance, whereas this mapping must hold for any request that arrives. A test asserts
+ * the two agree, so they cannot drift.
+ *
+ * Scoping to known ids is what keeps this safe: the oauth-provider plugin owns ~15 sibling `/oauth2/*`
+ * routes (`/oauth2/consent`, `/oauth2/userinfo`, `/oauth2/token`, …) for our own MCP OAuth server, and
+ * an unscoped prefix rewrite could shadow one that upstream adds later.
+ */
+export const PINNED_SSO_PROVIDER_IDS = ["azuread", "openid", "saml"] as const;
+
+/**
+ * The current-version URL for a legacy SSO callback request, or `null` when the request is not one.
+ *
+ * Matched as a SUFFIX, with the provider id exact and the prefix required to hold no second `/api/auth`.
+ * Together those two conditions make this function's output a local invariant rather than something the
+ * router has to clean up after: the only path it can ever produce is
+ * `/api/auth/callback/`. A suffix rather than an anchored prefix because a Next.js
+ * `basePath` deployment serves the app from a subpath, so the auth segment is not at position 0 — the
+ * same reason `better-auth-path-label.ts` locates it with `indexOf` rather than `startsWith` (see
+ * ENG-606); the single-auth-segment rule is what keeps that tolerance from also accepting a crafted
+ * `/api/auth/x/api/auth/oauth2/callback/openid`. That one would be harmless anyway — the rewrite only ever
+ * deletes an `/oauth2` segment, so it cannot reach an endpoint the caller could not already reach, and the
+ * result 404s — but this runs inside the `/api/auth/*` catch-all, where "harmless because the router
+ * rejects it" is a property worth owning here instead of inheriting.
+ *
+ * `/oauth2/callback/azuread/extra` and a trailing-slash form are both left alone: an IdP redirects to
+ * precisely the URI it has registered, and an auth path is the wrong place to invent equivalences. Query
+ * and fragment carry over untouched — the query is where `code` and `state` live.
+ */
+export const mapLegacySsoCallbackUrl = (requestUrl: string): string | null => {
+ let url: URL;
+ try {
+ url = new URL(requestUrl);
+ } catch {
+ return null;
+ }
+
+ // Only http(s). On a cannot-be-a-base URL (`data:`, `mailto:`) the `pathname` setter is a silent no-op,
+ // so the rewrite below would return the input unchanged — a non-rewrite escaping as a rewrite. Next
+ // only ever hands us http(s), but this is an exported pure function whose docblock states an
+ // unconditional invariant, so it should hold unconditionally.
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
+
+ const { pathname } = url;
+ const providerId = PINNED_SSO_PROVIDER_IDS.find((id) =>
+ pathname.endsWith(`${LEGACY_CALLBACK_SEGMENT}${id}`)
+ );
+ if (providerId === undefined) return null;
+
+ const prefix = pathname.slice(0, pathname.length - (LEGACY_CALLBACK_SEGMENT.length + providerId.length));
+ if (prefix.includes(`${AUTH_BASE_PATH}/`)) return null;
+
+ url.pathname = `${prefix}${CURRENT_CALLBACK_SEGMENT}${providerId}`;
+ return url.toString();
+};
+
+/**
+ * The request Better Auth should handle: rewritten when it names a pinned legacy SSO callback, and the
+ * original object otherwise (identity, so the common path allocates nothing).
+ *
+ * A rewrite rather than a redirect, so the single-use authorization `code` is not re-emitted in a
+ * `Location` header on the GET callback that every one of our providers actually uses. Note this does not
+ * hold for `response_mode=form_post`: Better Auth 1.7 itself 302s a POST callback to
+ * `${baseURL}/callback/{id}?code=…&state=…` before validating state (`api/routes/callback.mjs`), so on
+ * that path the code travels through a `Location` regardless of what we do here — which is also why the
+ * body still has to be forwarded below rather than dropped.
+ */
+export const mapLegacySsoCallbackRequest = (request: Request): Request => {
+ const mappedUrl = mapLegacySsoCallbackUrl(request.url);
+ if (mappedUrl === null) return request;
+
+ // GET/HEAD cannot carry a body; anything else (an IdP configured for `response_mode=form_post`)
+ // forwards the stream, which undici requires `duplex: "half"` for. `duplex` is absent from TypeScript's
+ // `RequestInit`, hence the cast.
+ const forwardsBody = request.method !== "GET" && request.method !== "HEAD";
+ return new Request(mappedUrl, {
+ method: request.method,
+ headers: request.headers,
+ // Rebuilding a Request keeps nothing that is not copied. Without this a client disconnect stops
+ // aborting `auth.handler` and its outbound IdP calls on the pinned path only — the pass-through path
+ // returns the original object and does keep it, so omitting it gives the two paths different abort
+ // behaviour.
+ signal: request.signal,
+ // `duplex` is absent from TypeScript's RequestInit; cast only that property so `method`, `headers`
+ // and `body` above keep their checking.
+ ...(forwardsBody ? { body: request.body, ...({ duplex: "half" } as RequestInit) } : {}),
+ });
+};
diff --git a/apps/web/modules/auth/lib/mcp-dcr-application-type.test.ts b/apps/web/modules/auth/lib/mcp-dcr-application-type.test.ts
new file mode 100644
index 000000000000..3be4a2fbaa0f
--- /dev/null
+++ b/apps/web/modules/auth/lib/mcp-dcr-application-type.test.ts
@@ -0,0 +1,143 @@
+import { describe, expect, test } from "vitest";
+import {
+ isDcrRegistration,
+ normalizeDcrRequest,
+ withInferredApplicationType,
+} from "./mcp-dcr-application-type";
+
+const BASE = "https://app.formbricks.test";
+const REGISTER = `${BASE}/api/auth/oauth2/register`;
+
+/**
+ * ENG-2343. Better Auth 1.7 hardcodes `application_type: "web"` for dynamic client registration, and a
+ * web client is refused any loopback redirect URI — which is exactly what a local MCP client uses. 1.6
+ * had no such validation, so a client that omits the field regressed from working to
+ * `400 invalid_redirect_uri` before consent. Neither the default nor the clients are ours to change, so
+ * the field is inferred here when the URIs make it unambiguous.
+ */
+describe("withInferredApplicationType (ENG-2343)", () => {
+ test("fills in native when a redirect URI is an http loopback", () => {
+ const body = JSON.stringify({ redirect_uris: ["http://127.0.0.1:33418/callback"] });
+
+ expect(JSON.parse(withInferredApplicationType(body))).toEqual({
+ redirect_uris: ["http://127.0.0.1:33418/callback"],
+ application_type: "native",
+ });
+ });
+
+ // The three hosts upstream itself accepts for native http, so the value we supply is guaranteed to
+ // pass the validation that runs immediately after.
+ test.each(["http://localhost:8080/cb", "http://127.0.0.1:1/cb", "http://[::1]:9000/cb"])(
+ "treats %s as native loopback",
+ (uri) => {
+ const result = JSON.parse(withInferredApplicationType(JSON.stringify({ redirect_uris: [uri] })));
+
+ expect(result.application_type).toBe("native");
+ }
+ );
+
+ /**
+ * Everything else is passed through so upstream decides, exactly as before. Inferring must never be
+ * the reason a registration succeeds that should have failed, nor the reason one fails at all.
+ */
+ test.each([
+ [
+ "an explicit application_type is never overridden",
+ { application_type: "web", redirect_uris: ["http://127.0.0.1:1/cb"] },
+ ],
+ ["a non-loopback https URI", { redirect_uris: ["https://app.example.com/cb"] }],
+ ["https on loopback (upstream refuses this for native)", { redirect_uris: ["https://127.0.0.1:1/cb"] }],
+ ["a non-loopback http host", { redirect_uris: ["http://10.0.0.5:1/cb"] }],
+ ["no redirect_uris at all", { client_name: "x" }],
+ ["an empty redirect_uris array", { redirect_uris: [] }],
+ ["a non-string entry", { redirect_uris: [42] }],
+ ])("leaves %s untouched", (_label, payload) => {
+ const body = JSON.stringify(payload);
+
+ expect(withInferredApplicationType(body)).toBe(body);
+ });
+
+ /**
+ * A native client may register a loopback URI *and* an https one (an app-claimed universal link).
+ * Upstream accepts that pair under `native`, and 1.6 accepted it unconditionally, so failing to infer
+ * here would newly break it — the regression this file exists to prevent. Widening to "at least one"
+ * cannot widen what upstream accepts: it refuses a non-loopback http redirect under `native` too, as
+ * the case below asserts.
+ */
+ test.each([
+ ["loopback alongside an https URI", ["http://127.0.0.1:1/cb", "https://app.example.com/cb"]],
+ ["an https URI listed first", ["https://app.example.com/cb", "http://localhost:7777/cb"]],
+ ])("infers native for %s", (_label, redirect_uris) => {
+ const result = JSON.parse(withInferredApplicationType(JSON.stringify({ redirect_uris })));
+
+ expect(result.application_type).toBe("native");
+ expect(result.redirect_uris).toEqual(redirect_uris);
+ });
+
+ // The security boundary the widening leans on: labelling a client `native` must not be a way to get a
+ // non-loopback http redirect registered. We still infer here, and upstream still refuses the URI —
+ // asserted end-to-end against the real validator in mcp-oauth-dcr.test.ts.
+ test("inferring native does not make a non-loopback http redirect acceptable", () => {
+ const redirect_uris = ["http://127.0.0.1:1/cb", "http://evil.example.com/cb"];
+ const result = JSON.parse(withInferredApplicationType(JSON.stringify({ redirect_uris })));
+
+ expect(result.application_type).toBe("native");
+ expect(result.redirect_uris).toEqual(redirect_uris);
+ });
+
+ // A malformed body must reach upstream unchanged and produce upstream's own error, not ours.
+ test.each(["not json", "[1,2,3]", "null", '"a string"'])("passes through %s unchanged", (body) => {
+ expect(withInferredApplicationType(body)).toBe(body);
+ });
+});
+
+describe("isDcrRegistration", () => {
+ test("matches a POST to the registration endpoint", () => {
+ expect(isDcrRegistration(new Request(REGISTER, { method: "POST", body: "{}" }))).toBe(true);
+ });
+
+ test.each([
+ ["a GET", new Request(REGISTER)],
+ [
+ "a sibling MCP OAuth route",
+ new Request(`${BASE}/api/auth/oauth2/token`, { method: "POST", body: "{}" }),
+ ],
+ [
+ "the SSO callback",
+ new Request(`${BASE}/api/auth/oauth2/callback/openid`, { method: "POST", body: "{}" }),
+ ],
+ ["an unrelated endpoint", new Request(`${BASE}/api/auth/sign-in/email`, { method: "POST", body: "{}" })],
+ ])("does not match %s", (_label, request) => {
+ expect(isDcrRegistration(request)).toBe(false);
+ });
+});
+
+describe("normalizeDcrRequest", () => {
+ test("rebuilds the registration with the inferred type and keeps the headers", async () => {
+ const request = new Request(REGISTER, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: "Bearer t" },
+ body: JSON.stringify({ redirect_uris: ["http://127.0.0.1:33418/callback"] }),
+ });
+
+ const normalized = await normalizeDcrRequest(request);
+
+ expect(normalized.headers.get("authorization")).toBe("Bearer t");
+ await expect(normalized.json()).resolves.toMatchObject({ application_type: "native" });
+ });
+
+ // A Request body is single-use, so the normalizer has to reconstruct even when it changes nothing —
+ // otherwise the body it consumed would be gone by the time Better Auth reads it.
+ test("still yields a readable body when nothing is inferred", async () => {
+ const body = JSON.stringify({ redirect_uris: ["https://app.example.com/cb"] });
+ const normalized = await normalizeDcrRequest(new Request(REGISTER, { method: "POST", body }));
+
+ await expect(normalized.text()).resolves.toBe(body);
+ });
+
+ test("returns the original object for a request it does not handle", async () => {
+ const request = new Request(`${BASE}/api/auth/sign-in/email`, { method: "POST", body: "{}" });
+
+ expect(await normalizeDcrRequest(request)).toBe(request);
+ });
+});
diff --git a/apps/web/modules/auth/lib/mcp-dcr-application-type.ts b/apps/web/modules/auth/lib/mcp-dcr-application-type.ts
new file mode 100644
index 000000000000..26171baf6536
--- /dev/null
+++ b/apps/web/modules/auth/lib/mcp-dcr-application-type.ts
@@ -0,0 +1,96 @@
+import "server-only";
+
+/**
+ * Default `application_type` to `"native"` on Dynamic Client Registration when the client asked for
+ * loopback redirect URIs and did not say which kind of client it is (ENG-2343).
+ *
+ * Better Auth 1.7 added redirect-URI validation that 1.6 did not have, and for dynamic registration it
+ * hardcodes the `application_type` default to `"web"`
+ * (`@better-auth/oauth-provider` — `applyOAuthClientRegistrationDefaults(client, … : "web")`, then
+ * `validateClientRedirectUri(uri, applicationType ?? "web")`). A `"web"` client is refused any loopback
+ * URI outright: `if (!isHttps || isRedirectLoopback) invalidRedirectUri(...)`.
+ *
+ * Loopback is exactly what a local MCP client uses — `http://127.0.0.1:/callback` — and the MCP
+ * SDK posts the client's metadata verbatim, so a client that omits `application_type` (MCP Inspector's
+ * shape) would get `400 invalid_redirect_uri` before consent on 1.7 having worked on 1.6. There is no
+ * plugin option for the default: it is a literal at the call site. Self-hosters cannot fix it either,
+ * because the clients are not theirs to change — so it is normalized here.
+ *
+ * The inference is narrow and spec-aligned. RFC 8252 §7.3 defines loopback redirection as the native-app
+ * pattern, so a registration that asks for one is a native client; a browser app would not. We fill the
+ * field in only when it is absent and at least one redirect URI is http on one of the three hosts
+ * upstream itself accepts for native (`localhost`, `127.0.0.1`, `[::1]`). Anything else is passed
+ * through untouched and upstream decides, exactly as before.
+ *
+ * Deliberately "at least one" rather than "all": a native client may legitimately register a loopback
+ * URI *and* an https one (an app-claimed universal link), a shape upstream accepts under `native` and
+ * 1.6 accepted unconditionally — requiring every URI to be loopback would have made that combination
+ * newly fail, which is the regression this whole file exists to prevent. Verified against the live
+ * endpoint that widening this does not widen what gets accepted: upstream refuses a non-loopback http
+ * redirect under `native` too (`native` + `http://evil.example.com` → `invalid_redirect_uri`), so the
+ * only URIs this can green-light are loopback and https ones. It never turns a rejected URI into an
+ * accepted one; it only stops a native client being misfiled as a web one.
+ */
+
+const DCR_PATH_SEGMENT = "/api/auth/oauth2/register";
+const NATIVE_HTTP_LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
+
+const isNativeHttpLoopback = (uri: unknown): boolean => {
+ if (typeof uri !== "string") return false;
+ try {
+ const url = new URL(uri);
+ return url.protocol === "http:" && NATIVE_HTTP_LOOPBACK_HOSTS.has(url.hostname);
+ } catch {
+ return false;
+ }
+};
+
+/** Whether this request is a dynamic client registration whose body we should look at. */
+export const isDcrRegistration = (request: Request): boolean => {
+ if (request.method !== "POST") return false;
+ try {
+ return new URL(request.url).pathname.endsWith(DCR_PATH_SEGMENT);
+ } catch {
+ return false;
+ }
+};
+
+/**
+ * The registration body with `application_type: "native"` filled in when it was absent and at least one
+ * redirect URI is an http loopback. Returns the input unchanged in every other case, including a body
+ * that is not JSON or not an object — this must never be the reason a registration fails.
+ */
+export const withInferredApplicationType = (body: string): string => {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(body);
+ } catch {
+ return body;
+ }
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return body;
+
+ const client = parsed as Record;
+ if (client.application_type !== undefined) return body;
+
+ const redirectUris = client.redirect_uris;
+ if (!Array.isArray(redirectUris) || redirectUris.length === 0) return body;
+ if (!redirectUris.some(isNativeHttpLoopback)) return body;
+
+ return JSON.stringify({ ...client, application_type: "native" });
+};
+
+/**
+ * The request Better Auth should handle. Reads the body only for a DCR POST, and always reconstructs
+ * with the body it read — a Request body is single-use, so it cannot be inspected and then reused.
+ */
+export const normalizeDcrRequest = async (request: Request): Promise => {
+ if (!isDcrRegistration(request)) return request;
+
+ const raw = await request.text();
+ return new Request(request.url, {
+ method: request.method,
+ headers: request.headers,
+ body: withInferredApplicationType(raw),
+ signal: request.signal,
+ });
+};
diff --git a/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts b/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts
index 73466cfbe7ec..cdc9f533c7b8 100644
--- a/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts
+++ b/apps/web/modules/auth/lib/mcp-oauth-dcr.test.ts
@@ -5,8 +5,9 @@ import { jwt } from "better-auth/plugins";
import { NextRequest } from "next/server";
import { describe, expect, test, vi } from "vitest";
import { GET as getProtectedResourceMetadata } from "@/app/.well-known/oauth-protected-resource/[[...resource]]/route";
+import { withInferredApplicationType } from "./mcp-dcr-application-type";
import { getMcpOauthProviderOptions } from "./mcp-oauth-provider-options";
-import { getAuthIssuerUrl, getMcpResourceUrl } from "./oauth-urls";
+import { getAuthIssuerUrl, getMcpResourceUrl, getOAuthUserInfoUrl } from "./oauth-urls";
// Env-dependent URL getters pinned; scope constants stay real — the whole point of this suite
// is to exercise the actual advertised-scope → DCR → authorize chain (ENG-1055).
@@ -32,7 +33,23 @@ const REDIRECT_URI = "http://127.0.0.1:33418/callback";
* full-scope client would mask that bug, so this suite must register via DCR only.
*/
const createAuthInstance = () => {
- const db = {};
+ // memoryAdapter needs every model it will touch declared up front — it does not create them
+ // lazily. Better Auth 1.7 added the resource tables, and without them the plugin's boot-time
+ // resource seeding logs `Model oauthResource not found in the DB` and every authorize fails.
+ const db: Record = {
+ user: [],
+ session: [],
+ account: [],
+ verification: [],
+ jwks: [],
+ oauthClient: [],
+ oauthAccessToken: [],
+ oauthRefreshToken: [],
+ oauthConsent: [],
+ oauthResource: [],
+ oauthClientResource: [],
+ oauthClientAssertion: [],
+ };
return betterAuth({
baseURL: BASE_URL,
secret: "mcp-oauth-dcr-test-secret",
@@ -68,6 +85,11 @@ const registerClient = async (auth: ReturnType, scope
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
+ // Better Auth 1.7 validates redirect URIs against the OIDC application type, and DCR without
+ // an explicit `application_type` defaults to "web" — for which ANY loopback redirect URI is
+ // refused. MCP clients listen on a loopback port, so they are native clients and must say so.
+ // See the sibling test below, which pins the refusal.
+ application_type: "native",
scope: scopes.join(" "),
}),
})
@@ -101,7 +123,105 @@ const requestAuthorize = async (
describe("MCP OAuth Dynamic Client Registration → authorize (real-client shape)", () => {
test("limits access tokens to the single MCP resource audience", () => {
- expect(getMcpOauthProviderOptions().validAudiences).toEqual([getMcpResourceUrl()]);
+ const { resources } = getMcpOauthProviderOptions();
+
+ expect(resources).toHaveLength(1);
+ expect(resources?.[0]).toMatchObject({ identifier: getMcpResourceUrl() });
+ });
+
+ /**
+ * The MCP resource server allow-lists the AS's UserInfo endpoint as a second acceptable audience,
+ * because the provider appends it to `aud` whenever `openid` is in the granted scopes. Every other
+ * test compares our derivation of that URL to our own derivation, which would hold for any string
+ * — including a wrong one. Asserting against the instance's own discovery document is what pins the
+ * equality the allow-list actually depends on: the provider builds `userinfo_endpoint` and the
+ * appended audience from the same `${baseURL}/oauth2/userinfo` expression.
+ */
+ test("the UserInfo audience we allow-list is the one the provider stamps", async () => {
+ const auth = createAuthInstance();
+
+ const response = await auth.handler(new Request(`${BASE_URL}/api/auth/.well-known/openid-configuration`));
+ const { userinfo_endpoint: userinfoEndpoint } = (await response.json()) as {
+ userinfo_endpoint: string;
+ };
+
+ expect(userinfoEndpoint).toBe(getOAuthUserInfoUrl());
+ });
+
+ /**
+ * The 1.7 redirect-URI rules (ENG-2343), and the fix for them.
+ *
+ * An MCP client registers a loopback callback such as http://127.0.0.1:PORT/callback. Under 1.7 that
+ * is legal only for a *native* client: DCR hardcodes the `application_type` default to "web", and
+ * `validateClientRedirectUri` refuses every loopback URI for web clients — so a client that omits the
+ * field is rejected before the user ever sees a consent screen. 1.6 had no such validation, so this
+ * regressed working clients, and neither the default (a literal at the call site, not an option) nor
+ * the clients are ours to change.
+ *
+ * These two tests are a pair: the first pins what upstream does, which is why the normalizer exists;
+ * the second proves the normalizer actually resolves it against that same real validator. Note the
+ * body is IDENTICAL in both — only `withInferredApplicationType` is applied.
+ */
+ const LOOPBACK_REGISTRATION = JSON.stringify({
+ client_name: "MCP DCR client that omits application_type",
+ redirect_uris: [REDIRECT_URI],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+ scope: "surveys:read",
+ });
+
+ const register = (auth: ReturnType, body: string) =>
+ auth.handler(
+ new Request(`${BASE_URL}/api/auth/oauth2/register`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body,
+ })
+ );
+
+ test("upstream refuses a loopback redirect URI when the client does not declare itself native", async () => {
+ const response = await register(createAuthInstance(), LOOPBACK_REGISTRATION);
+ const body = (await response.json()) as { error?: string };
+
+ expect(response.status).toBe(400);
+ expect(body.error).toBe("invalid_redirect_uri");
+ });
+
+ test("the inferred application_type makes that same registration succeed", async () => {
+ const response = await register(createAuthInstance(), withInferredApplicationType(LOOPBACK_REGISTRATION));
+ const body = (await response.json()) as { client_id?: string; application_type?: string; error?: string };
+
+ expect(body.error).toBeUndefined();
+ expect(response.status).toBeLessThan(300);
+ expect(body.client_id).toBeTruthy();
+ });
+
+ /**
+ * The security boundary the inference leans on. It fires whenever ANY redirect URI is http loopback,
+ * which is deliberately wider than "all of them" — a native client may legitimately pair a loopback
+ * URI with an https one. That widening is only safe because `native` does not relax the rule for a
+ * non-loopback http URI, so being labelled native can never be a route to registering one. Asserted
+ * against the real validator rather than reasoned about, because the whole class of bug here is
+ * upstream changing a rule we assumed.
+ */
+ test("being labelled native does not let a non-loopback http redirect register", async () => {
+ const payload = JSON.stringify({
+ client_name: "Mixed Client",
+ redirect_uris: ["http://127.0.0.1:9999/callback", "http://evil.example.com/callback"],
+ token_endpoint_auth_method: "none",
+ });
+ const inferred = withInferredApplicationType(payload);
+
+ // The inference does fire on this shape …
+ expect(JSON.parse(inferred).application_type).toBe("native");
+
+ // … and upstream still refuses the registration.
+ const response = await register(createAuthInstance(), inferred);
+ const body = (await response.json()) as { error?: string };
+
+ expect(response.status).toBe(400);
+ expect(body.error).toBe("invalid_redirect_uri");
});
test("PRM-advertised scopes register verbatim, including offline_access", async () => {
@@ -112,7 +232,8 @@ describe("MCP OAuth Dynamic Client Registration → authorize (real-client shape
const registration = await registerClient(auth, advertisedScopes);
- expect(registration.status).toBe(200);
+ // 201 Created since 1.7 (RFC 7591 §3.2.1).
+ expect(registration.status).toBe(201);
expect(registration.body.client_id).toBeTruthy();
// The registered scope set is what /authorize validates against — offline_access must survive.
expect(registration.body.scope?.split(" ")).toEqual(expect.arrayContaining(advertisedScopes));
@@ -134,12 +255,14 @@ describe("MCP OAuth Dynamic Client Registration → authorize (real-client shape
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
+ application_type: "native",
}),
})
);
const body = (await response.json()) as { scope?: string };
- expect(response.status).toBe(200);
+ // Better Auth 1.7 returns 201 Created here, per RFC 7591 §3.2.1; 1.6 answered 200.
+ expect(response.status).toBe(201);
expect(body.scope?.split(" ")).toEqual(
expect.arrayContaining([
"surveys:read",
@@ -170,13 +293,52 @@ describe("MCP OAuth Dynamic Client Registration → authorize (real-client shape
expect(authorize.location).toContain("/auth/login");
});
- test("authorize still rejects scopes outside the client's registration", async () => {
+ /**
+ * Behaviour change in Better Auth 1.7, pinned deliberately (ENG-2343).
+ *
+ * In 1.6 a client was registered with exactly the scopes it asked for, so a client that requested
+ * `surveys:read` could not later authorize `offline_access` — authorize answered `invalid_scope`.
+ * In 1.7 `clientRegistrationDefaultScopes` is applied regardless of what the client requested, so
+ * every DCR client is registered with the full advertised set and a narrower request no longer
+ * constrains it.
+ *
+ * That removes a boundary: a client can no longer self-limit at registration. It does NOT grant
+ * anything by itself — the token still only carries the scopes the user approves at consent, the
+ * per-tool guards check the token's scopes, and workspace permissions bound what those can reach.
+ * But "registered read-only" is no longer a thing, so it is asserted here rather than assumed.
+ */
+ test("registration grants the full default scope set even when the client asks for less", async () => {
const auth = createAuthInstance();
+
const registration = await registerClient(auth, ["surveys:read"]);
const clientId = registration.body.client_id;
expect(clientId).toBeTruthy();
+ expect(registration.body.scope?.split(" ")).toEqual(expect.arrayContaining(["surveys:write"]));
+
+ // Consequence: a scope the client never requested is now accepted at authorize.
+ //
+ // Asserted as a positive outcome, not as the absence of one error string. `requestAuthorize`
+ // defaults `location` to "" when the header is missing, and "" satisfies every `not.toContain` —
+ // so a negative assertion here would also pass if authorize returned a different error, or no
+ // redirect at all. What an accepted request actually does, unauthenticated, is bounce to the
+ // configured loginPage carrying no `error`.
const authorize = await requestAuthorize(auth, clientId as string, ["surveys:read", "offline_access"]);
+ expect(authorize.location).toBeTruthy();
+
+ const location = new URL(authorize.location, BASE_URL);
+ expect(location.pathname).toBe("/auth/login");
+ expect(location.searchParams.get("error")).toBeNull();
+ });
+
+ test("authorize still rejects a scope outside the advertised set entirely", async () => {
+ const auth = createAuthInstance();
+ const registration = await registerClient(auth, ["surveys:read"]);
+
+ const authorize = await requestAuthorize(auth, registration.body.client_id as string, [
+ "surveys:read",
+ "billing:admin",
+ ]);
expect(authorize.location).toContain("error=invalid_scope");
});
diff --git a/apps/web/modules/auth/lib/mcp-oauth-provider-options.test.ts b/apps/web/modules/auth/lib/mcp-oauth-provider-options.test.ts
index 268965fb0c0d..888849e928ad 100644
--- a/apps/web/modules/auth/lib/mcp-oauth-provider-options.test.ts
+++ b/apps/web/modules/auth/lib/mcp-oauth-provider-options.test.ts
@@ -14,25 +14,49 @@ vi.mock("@/lib/env", () => ({
}));
/**
- * `validAudiences` is the single mitigation standing between this deployment and
- * GHSA-p2fr-6hmx-4528, and until now nothing asserted it.
+ * The registered resource set is what binds an access token's audience to what the user approved
+ * (GHSA-p2fr-6hmx-4528). Better Auth 1.7 replaced the flat `validAudiences` allow-list with this
+ * model: a token is issued for a resource the grant covers rather than for whatever the client asked
+ * for. Declaring a second resource here would make cross-resource escalation possible again, so the
+ * single entry is asserted rather than assumed.
*
- * The provider does not bind an access token's `aud` to the resource approved at authorization: it
- * stamps the token with the whole `validAudiences` allow-list. With one entry there is no second
- * audience to escalate into, so the advisory cannot bite here. Add a second entry and it can —
- * silently, with every existing test still green. That is what this suite exists to stop.
- *
- * The resource server enforces the other half (rejecting a token that names an audience beyond this
- * one) in modules/mcp/auth.ts, which is required by RFC 9068 §4 no matter how the provider behaves.
+ * The resource server enforces the other half — refusing a token whose `aud` names anything beyond
+ * this resource and the AS's own UserInfo endpoint — in modules/mcp/auth.ts. RFC 9068 §4 puts that
+ * on the resource server regardless of how the provider behaves, and 1.7 makes it more load-bearing:
+ * the provider no longer checks the audience against the *calling* resource server at all.
*/
describe("getMcpOauthProviderOptions", () => {
// Also pinned in mcp-oauth-dcr.test.ts (#8828). The duplication is deliberate: this is the
// invariant the whole GHSA-p2fr-6hmx-4528 acceptance rests on, and the two suites can be deleted
// or rewritten independently. Do not "de-duplicate" this away.
- test("grants exactly one audience, so no token can be minted for a second resource server", () => {
- const { validAudiences } = getMcpOauthProviderOptions();
+ test("registers exactly one resource, so no token can be minted for a second resource server", () => {
+ const { resources } = getMcpOauthProviderOptions();
+
+ expect(resources).toHaveLength(1);
+ expect(resources?.[0]).toMatchObject({ identifier: getMcpResourceUrl() });
+ });
+
+ // enforcePerClientResources defaults to true, so a DCR client with no linked resource is refused
+ // `invalid_target` at the token endpoint — after the user has consented. This must stay in step
+ // with the registered resource above.
+ test("links every newly registered client to that same resource", () => {
+ const { clientRegistrationDefaultResources } = getMcpOauthProviderOptions();
+
+ expect(clientRegistrationDefaultResources).toEqual([getMcpResourceUrl()]);
+ });
+
+ // allowedScopes INTERSECTS the requested scopes instead of rejecting them, so a short list would
+ // silently strip openid/profile/email/offline_access from every token — no error, no id_token, no
+ // refresh. Pinned against the full constant.
+ test("allows the full advertised scope set on the resource, not just the resource scopes", () => {
+ const { resources } = getMcpOauthProviderOptions();
+
+ expect(resources?.[0]).toMatchObject({ allowedScopes: [...MCP_OAUTH_SCOPES] });
+ });
- expect(validAudiences).toEqual([getMcpResourceUrl()]);
+ // Boot-time config must never revert an operator's CRUD edit on restart.
+ test("seeds resources insert-only", () => {
+ expect(getMcpOauthProviderOptions().resourceSeedMode).toBe("insertOnly");
});
test("advertises only scopes it is willing to grant", () => {
diff --git a/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts b/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts
index f2bedb351186..645d0f5fd570 100644
--- a/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts
+++ b/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts
@@ -17,7 +17,53 @@ export const getMcpOauthProviderOptions = (): TOauthProviderOptions => ({
advertisedMetadata: {
scopes_supported: [...MCP_OAUTH_SCOPES],
},
- validAudiences: [getMcpResourceUrl()],
+ // Better Auth 1.7 replaced the flat `validAudiences` allow-list with persisted resources
+ // (ENG-2343). The difference is the point of the upgrade: 1.6 stamped a token with whatever the
+ // client asked for, checked only against this list, so nothing tied the token to what the user
+ // actually approved (GHSA-p2fr-6hmx-4528). 1.7 binds the grant instead.
+ //
+ // `allowedScopes` intersects the requested scopes rather than rejecting them, so it MUST be the
+ // full MCP_OAUTH_SCOPES set. Narrowing it to the six resource scopes would silently strip openid,
+ // profile, email and offline_access from every token — killing id_tokens and refresh with no error
+ // anywhere. Derived from the constant so the two cannot drift.
+ //
+ // No `accessTokenTtl` on purpose: leaving it unset keeps expiry driven by `accessTokenExpiresIn`
+ // and `scopeExpirations` below, preserving the 15-minute write step-up exactly as it works today.
+ // A per-resource TTL would be min()'d with those and only muddy the derivation.
+ resources: [
+ {
+ identifier: getMcpResourceUrl(),
+ name: "Formbricks MCP",
+ allowedScopes: [...MCP_OAUTH_SCOPES],
+ },
+ ],
+ // Boot-time config never overwrites a row an operator edited through the CRUD endpoints. This is
+ // the upstream default; pinned explicitly because a silent policy revert on restart would be very
+ // hard to attribute.
+ //
+ // ⚠ The flip side, for whoever changes a deployment's WEBAPP_URL after install: the resource
+ // identifier above is derived from it, so a new URL is a NEW resource. insertOnly means boot adds a
+ // second `oauthResource` row and points `clientRegistrationDefaultResources` at it, while every
+ // already-registered client keeps its `oauthClientResource` link to the OLD identifier — and with
+ // `enforcePerClientResources` on, those clients then fail `invalid_target` at the token endpoint,
+ // after the user has already consented. Nothing self-heals it, because insertOnly is what stops boot
+ // from rewriting operator-owned rows.
+ //
+ // Migrating the URL therefore means repointing the links, not just restarting: update the existing
+ // `oauthResource.identifier` in place (the FK from `oauthClientResource.resourceId` is
+ // ON UPDATE CASCADE, so the link rows follow), rather than letting a second row appear. The
+ // alternative — telling every MCP user to re-register their client — is the thing the ENG-2343
+ // backfill exists to avoid.
+ resourceSeedMode: "insertOnly",
+ // Mandatory, not optional. `enforcePerClientResources` defaults to true, and with no registration
+ // resources configured the plugin rejects every explicit resource request — which would break each
+ // MCP client the moment it registered.
+ clientRegistrationDefaultResources: [getMcpResourceUrl()],
+ // `cachedResources` is deliberately NOT set. Its cache is module-scoped with no TTL, invalidated
+ // only by CRUD writes in the same process, so on multiple replicas disabling a resource would not
+ // take effect until every pod restarted — defeating `disabled` as a revocation lever. It would
+ // save one indexed read per /oauth2/token call, which is not the hot path (/api/mcp verifies JWTs
+ // locally against a cached JWKS and never reads these tables).
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
// Register MCP clients with the full advertised scope set by default so the consent screen offers
@@ -51,10 +97,9 @@ export const getMcpOauthProviderOptions = (): TOauthProviderOptions => ({
introspect: { window: 60, max: 60 },
revoke: { window: 60, max: 30 },
},
- // Discovery is served by our Next.js catch-all at /.well-known/oauth-authorization-server/api/auth;
- // Better Auth can't introspect the route, so this acks the (verified-correct) endpoint rather than
- // masking a real problem. See PR #8447.
- silenceWarnings: {
- oauthAuthServerConfig: true,
- },
+ // `silenceWarnings` was removed in Better Auth 1.7 (ENG-2343). It acknowledged an
+ // `oauthAuthServerConfig` warning: discovery is served by our Next.js catch-all at
+ // /.well-known/oauth-authorization-server/api/auth, which Better Auth cannot introspect, so the
+ // warning was noise about a verified-correct endpoint rather than a real problem (PR #8447).
+ // Nothing replaces it upstream — if 1.7 still emits that warning it is expected and harmless here.
});
diff --git a/apps/web/modules/auth/lib/mcp-oauth-resource-seed.test.ts b/apps/web/modules/auth/lib/mcp-oauth-resource-seed.test.ts
new file mode 100644
index 000000000000..907e5180bd39
--- /dev/null
+++ b/apps/web/modules/auth/lib/mcp-oauth-resource-seed.test.ts
@@ -0,0 +1,48 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, test } from "vitest";
+import { MCP_OAUTH_SCOPES } from "./oauth-urls";
+
+/**
+ * ENG-2343. The data migration seeds the `oauthResource` row for instances that already have MCP clients,
+ * and it must carry the same `allowedScopes` the plugin would seed on a fresh install. It cannot import
+ * that list — `packages/database` may not depend on `apps/web`, and the migration is not an exported
+ * subpath — so it keeps a local copy, and this test is what makes the copy safe.
+ *
+ * Not cosmetic. `allowedScopes` **intersects** the requested scopes rather than validating them
+ * (`resolveResourcePolicy` in `@better-auth/oauth-provider`), and it skips only NULL/undefined. So a
+ * scope the app advertises but this row omits is silently intersected away, and a request for only that
+ * scope fails `invalid_scope` at `/authorize`. With `resourceSeedMode: "insertOnly"` the row is never
+ * repaired at boot, so any divergence is permanent for every upgraded instance.
+ *
+ * Read as text rather than imported: crossing the workspace boundary in a type-checked import would fight
+ * the app's tsconfig, and the value under test is a literal, so parsing it is sufficient.
+ */
+const MIGRATION_PATH =
+ "../../packages/database/migration/20260812110001_eng_2343_backfill_oauth_resource_links/migration.ts";
+
+const readSeededScopes = (): string[] => {
+ const source = readFileSync(resolve(process.cwd(), MIGRATION_PATH), "utf8");
+ const declaration = /export const MCP_RESOURCE_ALLOWED_SCOPES = \[([^\]]*)\]/.exec(source);
+ if (!declaration) {
+ throw new Error(
+ "MCP_RESOURCE_ALLOWED_SCOPES was not found in the migration — it was renamed or removed, which " +
+ "means the seeded resource may no longer allow the scopes the app grants."
+ );
+ }
+ return [...declaration[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
+};
+
+describe("the migration's seeded allowedScopes matches the advertised scope set (ENG-2343)", () => {
+ test("is exactly MCP_OAUTH_SCOPES, in the same order", () => {
+ expect(readSeededScopes()).toEqual([...MCP_OAUTH_SCOPES]);
+ });
+
+ // The failure mode that matters, stated on its own: a scope the app can grant that the resource row
+ // would intersect away.
+ test("allows every scope the app can grant", () => {
+ const allowed = new Set(readSeededScopes());
+
+ expect(MCP_OAUTH_SCOPES.filter((scope) => !allowed.has(scope))).toEqual([]);
+ });
+});
diff --git a/apps/web/modules/auth/lib/oauth-urls.ts b/apps/web/modules/auth/lib/oauth-urls.ts
index 1ec3088cbda9..68dc8eb2048e 100644
--- a/apps/web/modules/auth/lib/oauth-urls.ts
+++ b/apps/web/modules/auth/lib/oauth-urls.ts
@@ -53,12 +53,26 @@ export const getMcpOrigin = (): string => new URL(getMcpResourceUrl()).origin;
* Built off the issuer for the same reason `jwksUrl` is: Better Auth mounts its OAuth endpoints
* under the auth base path, so the issuer is the prefix the plugin itself uses.
*
- * The assumption is that this equals Better Auth's own `ctx.context.baseURL`, which is what it
- * stamps into the audience. That holds while the configured auth URL is a bare origin — Better
- * Auth's `withPath` appends `/api/auth` exactly as `getAuthIssuerUrl` does. It does NOT hold if
- * `BETTER_AUTH_URL` carries a subpath, because `withPath` returns a URL that already has a path
- * unchanged while we still append. Subpath deployments cannot complete a login at all today
- * (ENG-606), so this is not a live gap — but it is the thing to fix here when that one is fixed.
+ * The assumption is that this equals Better Auth's own `ctx.context.baseURL`, which is what it stamps
+ * into the audience. It holds for both shapes an operator is actually told to configure:
+ *
+ * - a bare origin — upstream's `withPath` appends `/api/auth`, exactly as `appendPath` does here;
+ * - a subpath already ending in `/api/auth` (`https://host/custom-path/api/auth`, which is the literal
+ * value `docs/self-hosting/configuration/custom-subpath.mdx` prescribes) — `withPath` returns it
+ * unchanged because `checkHasPath` is true, and `appendPath` returns it unchanged because its
+ * `basePath.endsWith(normalizedPath)` branch fires.
+ *
+ * The one shape where they diverge is a configured URL carrying a path that does NOT end in
+ * `/api/auth`: `withPath` leaves any non-empty path alone, while `appendPath` would append. Note this
+ * is narrower than it used to say here — "any subpath breaks it" is wrong, and the documented subpath
+ * is precisely the case that works. Subpath deployments cannot complete a login at all today (ENG-606),
+ * so it is still not a live gap.
+ *
+ * This matters beyond the audience now: `ssoLegacyRedirectUri` in better-auth-providers.ts builds the
+ * pinned SSO callback URL from `getAuthIssuerUrl()` (ENG-2343). Because that URL is pinned explicitly,
+ * Better Auth sends it on both the authorization and token legs regardless of its own `baseURL`, so a
+ * divergence here cannot desynchronise the handshake — it would only mean the URL names a host the
+ * operator did not intend, which is a configuration error rather than a protocol one.
*/
export const getOAuthUserInfoUrl = (): string => `${getAuthIssuerUrl()}/oauth2/userinfo`;
diff --git a/apps/web/modules/auth/lib/signup-policy.test.ts b/apps/web/modules/auth/lib/signup-policy.test.ts
index 2625728258be..52117eeb8fd3 100644
--- a/apps/web/modules/auth/lib/signup-policy.test.ts
+++ b/apps/web/modules/auth/lib/signup-policy.test.ts
@@ -78,7 +78,7 @@ describe("signupPolicyBeforeHandler", () => {
test("ignores every path other than the credential sign-up route", async () => {
closeTheInstance();
- for (const path of ["/sign-in/email", "/reset-password", "/oauth2/callback/openid", "/get-session"]) {
+ for (const path of ["/sign-in/email", "/reset-password", "/callback/openid", "/get-session"]) {
await expect(signupPolicyBeforeHandler({ path } as never)).resolves.toBeUndefined();
}
});
diff --git a/apps/web/modules/ee/analysis/charts/components/breakdown-bars.tsx b/apps/web/modules/ee/analysis/charts/components/breakdown-bars.tsx
new file mode 100644
index 000000000000..0e5f2708d2e6
--- /dev/null
+++ b/apps/web/modules/ee/analysis/charts/components/breakdown-bars.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import { useTranslation } from "react-i18next";
+import {
+ type TDistributionEntry,
+ buildDistributionSegments,
+ formatCellValue,
+ formatPercentShare,
+ getSemanticDimensionColor,
+ getSentimentMeasureColor,
+} from "@/modules/ee/analysis/charts/lib/chart-utils";
+import {
+ getMeasureAxisLabel,
+ sortMeasureIdsForCategoryAxis,
+} from "@/modules/ee/analysis/lib/schema-definition";
+import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
+
+interface BreakdownBarsProps {
+ /** Rows in the dimension's display order; sections are re-sorted by share, as the pie's are. */
+ sortedData: TChartDataRow[];
+ dataKeys: string[];
+ dataKey: string;
+ hasCategoryAxis: boolean;
+ xAxisKey: string;
+ formatDimensionValue: (value: unknown) => string;
+}
+
+/**
+ * A pie chart's other rendering: one horizontal bar split into a section per group, sized by each
+ * group's share of the total, with the count and share on hover. Chosen through the pie chart's
+ * "Breakdown bars" display setting.
+ *
+ * The same data a pie shows, in a fraction of the height — which is what makes it worth having for
+ * a single distribution like sentiment, where a pie spends a lot of vertical space on six slices.
+ * Sections take the sentiment scale colours when the query reads sentiment, and a measure-only
+ * query turns each measure into a section. Ordering and palette come from
+ * `buildDistributionSegments`, which sorts by share exactly as `preparePieData` does, so toggling
+ * the display leaves every group where it was, in the colour it had.
+ */
+export function BreakdownBars({
+ sortedData,
+ dataKeys,
+ dataKey,
+ hasCategoryAxis,
+ xAxisKey,
+ formatDimensionValue,
+}: Readonly) {
+ const { t, i18n } = useTranslation();
+
+ let entries: TDistributionEntry[];
+ if (hasCategoryAxis) {
+ // Grouped query: one section per row, the first measure supplying the size.
+ entries = sortedData.map((row, index) => ({
+ key: `${String(row[xAxisKey] ?? "")}-${index}`,
+ label: formatDimensionValue(row[xAxisKey]),
+ value: row[dataKey],
+ color: getSemanticDimensionColor(xAxisKey, row[xAxisKey]),
+ }));
+ } else {
+ // Measure-only query: each measure is its own section. Sorted into the sentiment scale order
+ // first so that measures with an equal count still come out in a meaningful order.
+ entries = sortMeasureIdsForCategoryAxis(dataKeys).map((key) => ({
+ key,
+ label: getMeasureAxisLabel(key, t),
+ value: sortedData.reduce((sum, row) => sum + (Number(row[key]) || 0), 0),
+ color: getSentimentMeasureColor(key),
+ }));
+ }
+
+ const result = buildDistributionSegments(entries);
+ if (!result) {
+ return (
+
+ );
+ }
+
+ // Formatted once per section and shared by the bar and the legend. The `value (share)` and
+ // `label: value (share)` templates are translated, so a locale controls its own punctuation and
+ // the order of the two numbers.
+ const formattedSegments = result.segments.map((segment) => {
+ const value = formatCellValue(segment.value);
+ const percent = formatPercentShare(segment.percent, i18n.language);
+ return {
+ ...segment,
+ valueShare: t("workspace.analysis.charts.distribution_value_share", { value, percent }),
+ ariaLabel: t("workspace.analysis.charts.distribution_segment_label", {
+ label: segment.label,
+ value,
+ percent,
+ }),
+ };
+ });
+
+ return (
+
+
+ {/* The sections carry no text of their own: a label wide enough for the widest section is
+ still clipped on the narrow ones, at which point it reads as noise rather than data.
+ The legend below names every section instead, at a size that does not depend on how
+ the shares happen to fall. */}
+
+ {formattedSegments.map((segment) => (
+
+ {/* The section is the tooltip's trigger, so it is a real button rather than a
+ focusable div: keyboard users reach it natively and its label is announced as
+ the control it is. Shrink (never grow) so the gaps come out of the sections
+ proportionally and the widths stay a faithful read of each share. */}
+
+
+
+
+ {segment.label}
+ {segment.valueShare}
+
+
+
+ ))}
+
+
+ {/* Every section named, in bar order (largest share first). */}
+
+ {formattedSegments.map((segment) => (
+
+
+ {segment.label}
+ {segment.valueShare}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx b/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx
index 66324892352a..47b0c92dce53 100644
--- a/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx
+++ b/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx
@@ -1,8 +1,13 @@
"use client";
-import { type ElementType, type ReactNode } from "react";
+import { type ElementType, type ReactNode, useMemo } from "react";
import { CartesianGrid, XAxis, YAxis } from "recharts";
-import { formatXAxisTick } from "@/modules/ee/analysis/charts/lib/chart-utils";
+import {
+ formatCellValue,
+ formatXAxisTick,
+ getCategoryAxisWidth,
+ getValueLabelPadding,
+} from "@/modules/ee/analysis/charts/lib/chart-utils";
import { type YAxisScale, computeYAxis } from "@/modules/ee/analysis/charts/lib/y-axis-scale";
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
import type { ChartConfig } from "@/modules/ui/components/chart";
@@ -39,6 +44,10 @@ export interface CartesianChartProps {
* boundary. Anchors the edge x-axis labels inward so they aren't clipped by the plot edge.
* Leave false for band-scale charts (bars), whose edge categories are already inset. */
pointScale?: boolean;
+ /** Flips the chart onto its side: categories run down the y-axis and values across the x-axis.
+ * Bar charts only — the category labels move into a gutter on the left, sized to the labels
+ * present and wrapped inside it (see `getCategoryAxisWidth`). */
+ horizontal?: boolean;
}
/** Upper bound (px) on a single x-axis label before wrapping. The per-category band clamp below
@@ -141,6 +150,62 @@ function WrappingXAxisTick({
);
}
+/** Category tick for a flipped (horizontal) chart. Same `foreignObject` wrapping trick as
+ * `WrappingXAxisTick`, but the box hangs to the left of the axis line and is centred on its
+ * category band, since here the labels stack down the y-axis.
+ *
+ * The box height is clamped to the band the same way `WrappingXAxisTick` clamps its width: the
+ * chart's height comes from its container, not from the row count, so the band shrinks as categories
+ * are added. A fixed three-line box overlaps its neighbours as soon as the band falls below it, so
+ * the label sheds lines instead — down to a single line, with the full text still on hover. */
+function WrappingYAxisTick({
+ x,
+ y,
+ payload,
+ formatter,
+ axisWidth,
+ height,
+ visibleTicksCount,
+}: Readonly<{
+ x?: number;
+ y?: number;
+ payload?: { value?: unknown };
+ formatter: (value: unknown) => string;
+ /** Gutter the axis reserved, so the label box matches it instead of a fixed maximum. */
+ axisWidth: number;
+ height?: number;
+ visibleTicksCount?: number;
+}>) {
+ const label = formatter(payload?.value);
+ const boxWidth = Math.max(1, axisWidth - X_AXIS_TICK_GAP);
+
+ const band = height && visibleTicksCount ? height / visibleTicksCount : X_AXIS_LABEL_BOX_HEIGHT;
+ const boxHeight = Math.max(
+ X_AXIS_LABEL_LINE_HEIGHT,
+ Math.min(X_AXIS_LABEL_BOX_HEIGHT, band - X_AXIS_TICK_GAP)
+ );
+ // Whole lines only — a box sized to 2.5 lines would clip the third mid-glyph rather than drop it.
+ const lineClamp = Math.max(1, Math.floor(boxHeight / X_AXIS_LABEL_LINE_HEIGHT));
+
+ return (
+
+
+
+ {label}
+
+
+
+ );
+}
+
export function CartesianChart({
data,
xAxisKey,
@@ -157,43 +222,91 @@ export function CartesianChart({
tooltipHideLabel,
yAxisScale,
pointScale = false,
+ horizontal = false,
}: Readonly) {
const yScale = yAxisScale ?? computeYAxis(data, dataKeys, zeroBaseline);
const tickFormatter = xAxisTickFormatter ?? formatXAxisTick;
+ const categoryAxisWidth = useMemo(() => {
+ if (!horizontal || !hasCategoryAxis) return 0;
+ return getCategoryAxisWidth(data.map((row) => tickFormatter(row[xAxisKey])));
+ }, [horizontal, hasCategoryAxis, data, xAxisKey, tickFormatter]);
+
+ // Flipped, a bar's value label sits past its end with nothing reserving room for it, so the
+ // longest bar loses its label whenever the data max lands on the axis bound. The vertical axis
+ // solves the same problem with `padding.top`; this is that padding, sized to the widest label.
+ const valueLabelPadding = useMemo(() => {
+ if (!horizontal) return 0;
+ const labels = data.flatMap((row) => dataKeys.map((key) => formatCellValue(row[key])));
+ return getValueLabelPadding(labels);
+ }, [horizontal, data, dataKeys]);
return (
-
+
{/* syncWithTicks: draw a gridline only at each tick. Without it Recharts adds
extra lines at the plot-area top/bottom edges (revealed by the YAxis padding),
- which showed up as unlabelled boundary lines above 80 and below 0. */}
-
-
- ) : (
- false
- )
- }
- />
-
+ which showed up as unlabelled boundary lines above 80 and below 0. The gridlines
+ always run across the value axis, which flips with the layout. */}
+
+ {/* Flipped charts swap the axis roles: values run along the x-axis and the categories
+ stack down the y-axis. */}
+ {horizontal ? (
+
+ ) : (
+
+ ) : (
+ false
+ )
+ }
+ />
+ )}
+ {horizontal ? (
+
+ ) : (
+ false
+ )
+ }
+ />
+ ) : (
+
+ )}
void;
+}
+
+/**
+ * Display settings saved with the chart, so they apply wherever it renders (preview, chart
+ * list, dashboard widget) rather than only to the preview. Settings that the current chart
+ * type doesn't support are hidden instead of shown inert.
+ */
+export function ChartDisplaySettings({ chartType, config, onChange }: Readonly) {
+ const { t } = useTranslation();
+ const { barOrientation, pieDisplay } = resolveChartDisplay(config);
+ const showBarOrientation = supportsBarOrientation(chartType);
+ const showPieDisplay = supportsPieDisplay(chartType);
+ // Generated rather than hardcoded: two of these panels on one page would otherwise share ids.
+ const barOrientationLabelId = useId();
+ const pieDisplayLabelId = useId();
+
+ // For a chart type with no applicable setting the section would be a heading with nothing under it.
+ if (!showBarOrientation && !showPieDisplay) return null;
+
+ return (
+