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 ( +
+ {t("workspace.analysis.charts.no_valid_data_to_display")} +
+ ); + } + + // 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) => ( +
  • +
  • + ))} +
+
+ ); +} 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 ( +
+

+ {t("workspace.analysis.charts.chart_display_settings")} +

+ +
+ {showPieDisplay && ( +
+ + , + }, + { + value: "breakdown", + label: t("workspace.analysis.charts.pie_display_breakdown"), + icon: , + }, + ]} + currentOption={pieDisplay} + handleOptionChange={(value) => onChange({ ...config, pieDisplay: value as TPieDisplay })} + /> +
+ )} + {showBarOrientation && ( +
+ + , + }, + { + value: "horizontal", + label: t("workspace.analysis.charts.horizontal_bars"), + icon: , + }, + ]} + currentOption={barOrientation} + handleOptionChange={(value) => + onChange({ ...config, barOrientation: value as TBarOrientation }) + } + /> +
+ )} +
+
+ ); +} diff --git a/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx b/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx index eaa9139076e9..5b10a36f33dd 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx @@ -3,6 +3,7 @@ import { BarChart, DatabaseIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import type { TChartConfig } from "@formbricks/types/analysis"; import { ChartErrorBoundary } from "@/modules/ee/analysis/charts/components/chart-error-boundary"; import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer"; import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer"; @@ -12,6 +13,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/component interface ChartPreviewProps { chartData: AnalyticsResponse | null; + /** Display settings being edited, so the preview renders what will be saved. */ + config?: TChartConfig; isLoading?: boolean; error?: string | null; emptyMessage?: string; @@ -19,6 +22,7 @@ interface ChartPreviewProps { export function ChartPreview({ chartData, + config, isLoading = false, error, emptyMessage, @@ -87,6 +91,7 @@ export function ChartPreview({ data={data} query={chartData.query} optionLabels={chartData.optionLabels} + config={config} /> diff --git a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx index 88d86126009e..a668971e4bd8 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx @@ -3,10 +3,12 @@ import { useId } from "react"; import { useTranslation } from "react-i18next"; import { Area, AreaChart, Bar, BarChart, Cell, Label, LabelList, Legend, Pie, PieChart } from "recharts"; -import type { TChartQuery } from "@formbricks/types/analysis"; +import type { TChartConfig, TChartQuery } from "@formbricks/types/analysis"; import { cn } from "@/lib/cn"; +import { BreakdownBars } from "@/modules/ee/analysis/charts/components/breakdown-bars"; import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesian-chart"; import { PolishedChartTooltip } from "@/modules/ee/analysis/charts/components/polished-tooltip"; +import { resolveChartDisplay } from "@/modules/ee/analysis/charts/lib/chart-display"; import { CHART_BRAND_DARK, CHART_MEASURE_COLORS, @@ -15,6 +17,7 @@ import { PIVOTED_MEASURE_KEY, PIVOTED_VALUE_KEY, formatCellValue, + formatPercentShare, formatXAxisTick, getSemanticDimensionColor, getSentimentMeasureColor, @@ -51,26 +54,29 @@ const PIE_LABEL_MIN_PERCENT = 0.02; /** Shown instead of a number when a measure had nothing to compute (an en dash, not a zero). */ const NO_DATA_PLACEHOLDER = "\u2013"; -const renderPieLabel = ({ cx, cy, midAngle, outerRadius, percent, value }: PieLabelProps) => { - if (cx == null || cy == null || midAngle == null || outerRadius == null || percent == null) return null; - if (percent < PIE_LABEL_MIN_PERCENT) return null; - const RADIAN = Math.PI / 180; - const radius = outerRadius + 22; - const x = cx + radius * Math.cos(-midAngle * RADIAN); - const y = cy + radius * Math.sin(-midAngle * RADIAN); - const textAnchor = x > cx ? "start" : "end"; - return ( - - {formatCellValue(value)} ({(percent * 100).toFixed(1)}%) - - ); -}; +// Curried with the active language: recharts calls the renderer outside React, so the locale has +// to be closed over rather than read from a hook inside it. +const createPieLabelRenderer = (locale: string) => + function PieSliceLabel({ cx, cy, midAngle, outerRadius, percent, value }: PieLabelProps) { + if (cx == null || cy == null || midAngle == null || outerRadius == null || percent == null) return null; + if (percent < PIE_LABEL_MIN_PERCENT) return null; + const RADIAN = Math.PI / 180; + const radius = outerRadius + 22; + const x = cx + radius * Math.cos(-midAngle * RADIAN); + const y = cy + radius * Math.sin(-midAngle * RADIAN); + const textAnchor = x > cx ? "start" : "end"; + return ( + + {formatCellValue(value)} ({formatPercentShare(percent, locale)}) + + ); + }; interface PieLabelLineProps { percent?: number; @@ -136,6 +142,7 @@ interface BarChartViewProps { xAxisKey: string; chartConfig: ChartConfig; formatDimensionValue: (value: unknown) => string; + isHorizontal?: boolean; } const BarChartView = ({ @@ -146,8 +153,12 @@ const BarChartView = ({ xAxisKey, chartConfig, formatDimensionValue, + isHorizontal = false, }: Readonly) => { const { t } = useTranslation(); + // Value labels sit past the end of the bar, which is the top of a vertical bar and the + // right-hand end of a horizontal one. + const valueLabelPosition = isHorizontal ? "right" : "top"; // Measure-only queries (no dimension or time grouping) return a single row with one // column per measure. Rendered as N bar series that row forms a single category band @@ -183,11 +194,12 @@ const BarChartView = ({ tooltipCursor={false} zeroBaseline tooltipHideLabel + horizontal={isHorizontal} xAxisTickFormatter={formatMeasureLabel}> formatCellValue(value)} @@ -219,6 +231,7 @@ const BarChartView = ({ tooltipCursor={false} zeroBaseline hasCategoryAxis={hasCategoryAxis} + horizontal={isHorizontal} xAxisTickFormatter={formatDimensionValue} chartProps={isMultiMeasure ? { barCategoryGap: "20%" } : {}}> {dataKeys.map((key) => ( @@ -226,7 +239,7 @@ const BarChartView = ({ {!isMultiMeasure && ( formatCellValue(value)} @@ -261,7 +274,8 @@ const PieChartView = ({ chartConfig, formatDimensionValue, }: Readonly) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + const renderPieLabel = createPieLabelRenderer(i18n.language); // With several measures and no dimension (e.g. the six Emotion counts), each row column is a // measure, not a slice — pivot the measures into one slice per measure so the pie shows them @@ -337,10 +351,20 @@ interface ChartRendererProps { query: TChartQuery; /** value_id → default-language label map, present when the query groups by valueId. */ optionLabels?: Record; + /** Saved display settings. Charts saved before these existed have an empty config and keep + * the previous behavior (vertical bars). */ + config?: TChartConfig; } -export function ChartRenderer({ chartType, data, query, optionLabels }: Readonly) { +export function ChartRenderer({ + chartType, + data, + query, + optionLabels, + config, +}: Readonly) { const { t } = useTranslation(); + const { barOrientation, pieDisplay } = resolveChartDisplay(config); // Unique across charts on the same page so SVG ids don't collide. const gradientIdPrefix = useId(); @@ -419,6 +443,7 @@ export function ChartRenderer({ chartType, data, query, optionLabels }: Readonly xAxisKey={xAxisKey} chartConfig={chartConfig} formatDimensionValue={formatDimensionValue} + isHorizontal={barOrientation === "horizontal"} /> ); case "line": @@ -491,6 +516,20 @@ export function ChartRenderer({ chartType, data, query, optionLabels }: Readonly ); case "pie": + // A pie and a breakdown bar answer the same question — the share each group takes of the + // whole — so they are two renderings of one chart type rather than two chart types. + if (pieDisplay === "breakdown") { + return ( + + ); + } return ( + {chartData && ( + + )} +
{ diff --git a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts index 96ae79f52dbf..119755563229 100644 --- a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts +++ b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts @@ -4,6 +4,7 @@ import { usePathname, useRouter } from "next/navigation"; import { useEffect, useRef, useState, useTransition } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; +import type { TChartConfig } from "@formbricks/types/analysis"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { createChartAction, @@ -12,6 +13,7 @@ import { getChartAction, updateChartAction, } from "@/modules/ee/analysis/charts/actions"; +import { sanitizeChartDisplay } from "@/modules/ee/analysis/charts/lib/chart-display"; import { resolveChartType } from "@/modules/ee/analysis/charts/lib/chart-utils"; import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions"; import type { @@ -49,6 +51,8 @@ export function useChartDialog({ const [, startTransition] = useTransition(); const [selectedChartType, setSelectedChartType] = useState(); const [chartData, setChartData] = useState(null); + // Display settings saved alongside the chart (display type, bar direction). + const [chartConfig, setChartConfig] = useState({}); const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false); const [chartName, setChartName] = useState(""); // Saved name of the chart being edited; unlike chartName it stays stable while the user types. @@ -93,6 +97,7 @@ export function useChartDialog({ lastSuggestedNameRef.current = null; setSelectedChartType(undefined); setCurrentChartId(undefined); + setChartConfig({}); setSelectedDirectoryId(directories?.[0]?.id ?? null); return; } @@ -119,6 +124,7 @@ export function useChartDialog({ setSavedChartName(chart.name); setSelectedChartType(resolveChartType(chart.type)); setCurrentChartId(chart.id); + setChartConfig(chart.config ?? {}); setSelectedDirectoryId(chart.feedbackDirectoryId); const queryResult = await executeQueryAction({ @@ -203,6 +209,7 @@ export function useChartDialog({ setIsSaving(true); let newlyCreatedChartId: string | null = null; + const configToSave = sanitizeChartDisplay(chartConfig, chartData.chartType); try { let savedChartId = currentChartId; @@ -214,7 +221,7 @@ export function useChartDialog({ name: chartName.trim(), type: chartData.chartType, query: chartData.query, - config: {}, + config: configToSave, }, }); @@ -232,7 +239,7 @@ export function useChartDialog({ name: chartName.trim(), type: chartData.chartType, query: chartData.query, - config: {}, + config: configToSave, feedbackDirectoryId: selectedDirectoryId, }, }); @@ -309,7 +316,7 @@ export function useChartDialog({ name: chartName.trim(), type: data.chartType, query: data.query, - config: {}, + config: sanitizeChartDisplay(chartConfig, data.chartType), feedbackDirectoryId: selectedDirectoryId, }, }); @@ -386,6 +393,7 @@ export function useChartDialog({ lastSuggestedNameRef.current = null; setSelectedChartType(undefined); setCurrentChartId(undefined); + setChartConfig({}); setChartLoadError(null); setSelectedDirectoryId(directories?.[0]?.id ?? null); onOpenChange(false); @@ -401,6 +409,8 @@ export function useChartDialog({ return { chartData, + chartConfig, + setChartConfig, chartName, setChartName, savedChartName, diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-display.test.ts b/apps/web/modules/ee/analysis/charts/lib/chart-display.test.ts new file mode 100644 index 000000000000..bcbce4acd887 --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/lib/chart-display.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "vitest"; +import { + resolveChartDisplay, + sanitizeChartDisplay, + supportsBarOrientation, + supportsPieDisplay, +} from "./chart-display"; + +describe("resolveChartDisplay", () => { + test("falls back to vertical bars for charts saved before these settings existed", () => { + expect(resolveChartDisplay({})).toEqual({ barOrientation: "vertical", pieDisplay: "pie" }); + expect(resolveChartDisplay(undefined)).toEqual({ barOrientation: "vertical", pieDisplay: "pie" }); + expect(resolveChartDisplay(null)).toEqual({ barOrientation: "vertical", pieDisplay: "pie" }); + }); + + test("returns the saved setting", () => { + expect(resolveChartDisplay({ barOrientation: "horizontal" })).toEqual({ + barOrientation: "horizontal", + pieDisplay: "pie", + }); + }); +}); + +describe("supportsBarOrientation", () => { + test("only bar charts have an orientation", () => { + expect(supportsBarOrientation("bar")).toBe(true); + expect(supportsBarOrientation("area")).toBe(false); + expect(supportsBarOrientation("line")).toBe(false); + expect(supportsBarOrientation("pie")).toBe(false); + expect(supportsBarOrientation("big_number")).toBe(false); + expect(supportsBarOrientation(undefined)).toBe(false); + }); +}); + +describe("sanitizeChartDisplay", () => { + test("keeps the orientation on a bar chart", () => { + expect(sanitizeChartDisplay({ barOrientation: "horizontal" }, "bar")).toEqual({ + barOrientation: "horizontal", + }); + }); + + test("drops the orientation for chart types that cannot use it", () => { + expect(sanitizeChartDisplay({ barOrientation: "horizontal" }, "pie")).toEqual({}); + }); + + test("preserves unrelated config fields", () => { + expect(sanitizeChartDisplay({ xAxisLabel: "Question", barOrientation: "horizontal" }, "area")).toEqual({ + xAxisLabel: "Question", + }); + }); + + test("returns an empty config when nothing is set", () => { + expect(sanitizeChartDisplay(undefined, "bar")).toEqual({}); + expect(sanitizeChartDisplay({}, "bar")).toEqual({}); + }); +}); + +describe("pie display", () => { + test("falls back to the pie for charts saved before this setting existed", () => { + expect(resolveChartDisplay({}).pieDisplay).toBe("pie"); + expect(resolveChartDisplay(undefined).pieDisplay).toBe("pie"); + }); + + test("returns the saved setting", () => { + expect(resolveChartDisplay({ pieDisplay: "breakdown" }).pieDisplay).toBe("breakdown"); + }); + + test("only a pie chart supports it", () => { + expect(supportsPieDisplay("pie")).toBe(true); + expect(supportsPieDisplay("bar")).toBe(false); + expect(supportsPieDisplay(undefined)).toBe(false); + }); + + test("keeps the setting for a pie and drops it for anything else", () => { + expect(sanitizeChartDisplay({ pieDisplay: "breakdown" }, "pie")).toEqual({ + pieDisplay: "breakdown", + }); + // Switching a breakdown pie to a bar chart must not leave the setting behind to surprise + // whoever switches it back. + expect(sanitizeChartDisplay({ pieDisplay: "breakdown" }, "bar")).toEqual({}); + }); + + test("each chart type keeps only its own setting", () => { + expect(sanitizeChartDisplay({ barOrientation: "horizontal", pieDisplay: "breakdown" }, "pie")).toEqual({ + pieDisplay: "breakdown", + }); + expect(sanitizeChartDisplay({ barOrientation: "horizontal", pieDisplay: "breakdown" }, "bar")).toEqual({ + barOrientation: "horizontal", + }); + }); + + test("preserves unrelated config either way", () => { + expect(sanitizeChartDisplay({ pieDisplay: "breakdown", showLegend: true }, "pie")).toEqual({ + pieDisplay: "breakdown", + showLegend: true, + }); + }); +}); diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-display.ts b/apps/web/modules/ee/analysis/charts/lib/chart-display.ts new file mode 100644 index 000000000000..eab23ed4b811 --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/lib/chart-display.ts @@ -0,0 +1,43 @@ +import type { TChartConfig } from "@formbricks/types/analysis"; +import type { TChartType } from "@/modules/ee/analysis/types/analysis"; + +export type TBarOrientation = NonNullable; +export type TPieDisplay = NonNullable; + +/** Charts render with vertical bars unless the saved config says otherwise. */ +export const DEFAULT_BAR_ORIENTATION: TBarOrientation = "vertical"; +/** A pie chart renders as a pie unless the saved config says otherwise. */ +export const DEFAULT_PIE_DISPLAY: TPieDisplay = "pie"; + +/** Each setting so far belongs to exactly one chart type. */ +export const supportsBarOrientation = (chartType: TChartType | undefined): boolean => chartType === "bar"; +export const supportsPieDisplay = (chartType: TChartType | undefined): boolean => chartType === "pie"; + +/** + * Resolves the display settings a chart renders with. Charts saved before these settings + * existed have an empty config, so every field falls back to the previous behavior. + */ +export const resolveChartDisplay = ( + config: TChartConfig | null | undefined +): { barOrientation: TBarOrientation; pieDisplay: TPieDisplay } => ({ + barOrientation: config?.barOrientation ?? DEFAULT_BAR_ORIENTATION, + pieDisplay: config?.pieDisplay ?? DEFAULT_PIE_DISPLAY, +}); + +/** + * Config to persist for a chart type: settings that the type does not support are dropped + * rather than saved as dead values, so switching a bar chart to a pie chart doesn't keep a + * stale orientation around to surprise whoever switches it back. + */ +export const sanitizeChartDisplay = ( + config: TChartConfig | null | undefined, + chartType: TChartType | undefined +): TChartConfig => { + const { barOrientation, pieDisplay, ...rest } = config ?? {}; + + return { + ...rest, + ...(supportsBarOrientation(chartType) && barOrientation ? { barOrientation } : {}), + ...(supportsPieDisplay(chartType) && pieDisplay ? { pieDisplay } : {}), + }; +}; diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts b/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts index b5579ebdc7ed..1b8218a53206 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "vitest"; import { SENTIMENT_VALUE_ORDER } from "@/modules/ee/analysis/lib/schema-definition"; import { + CATEGORY_AXIS_MAX_WIDTH, + CATEGORY_AXIS_MIN_WIDTH, CHART_BRAND_DARK, CHART_MEASURE_COLORS, CHART_NOT_ENRICHED_COLOR, @@ -9,10 +11,16 @@ import { PIE_MEASURE_VALUE_KEY, PIVOTED_MEASURE_KEY, PIVOTED_VALUE_KEY, + VALUE_LABEL_MAX_PADDING, + VALUE_LABEL_MIN_PADDING, + buildDistributionSegments, formatCellValue, + formatPercentShare, formatXAxisTick, + getCategoryAxisWidth, getSemanticDimensionColor, getSentimentMeasureColor, + getValueLabelPadding, pivotMeasuresToCategories, prepareMeasureSliceData, preparePieData, @@ -53,6 +61,14 @@ describe("chart-utils", () => { { [PIE_MEASURE_NAME_KEY]: "L:m.anger", [PIE_MEASURE_VALUE_KEY]: 2, tooltipLabel: "L:m.anger" }, ]); }); + + // ENG-2346: the tooltip falls back to formatting a row's dataKey when the row carries no + // `tooltipLabel`, and the slice value lives under a synthetic key that is not a Cube column — + // so a missing label surfaced to users as the literal "__measure Value". + test("carries the translated measure label so the tooltip never formats the synthetic value key", () => { + const result = prepareMeasureSliceData([{ "m.joy": 236 }], ["m.joy"], label); + expect(result[0].tooltipLabel).toBe("L:m.joy"); + }); }); describe("resolveChartType", () => { @@ -70,6 +86,66 @@ describe("chart-utils", () => { }); }); + describe("buildDistributionSegments", () => { + test("sizes each section by its share of the total", () => { + const result = buildDistributionSegments([ + { key: "a", label: "A", value: 30 }, + { key: "b", label: "B", value: 10 }, + ]); + expect(result).not.toBeNull(); + expect(result!.total).toBe(40); + expect(result!.segments.map((s) => s.percent)).toEqual([0.75, 0.25]); + }); + + test("orders sections largest first, the order preparePieData uses", () => { + const result = buildDistributionSegments([ + { key: "small", label: "Small", value: 1 }, + { key: "big", label: "Big", value: 99 }, + ]); + expect(result!.segments.map((s) => s.key)).toEqual(["big", "small"]); + }); + + test("keeps the caller's order for equal shares, so the palette handout is stable", () => { + const result = buildDistributionSegments([ + { key: "a", label: "A", value: 5 }, + { key: "b", label: "B", value: 5 }, + { key: "c", label: "C", value: 5 }, + ]); + expect(result!.segments.map((s) => s.key)).toEqual(["a", "b", "c"]); + }); + + test("drops non-positive and non-numeric entries from the total", () => { + const result = buildDistributionSegments([ + { key: "a", label: "A", value: 10 }, + { key: "zero", label: "Zero", value: 0 }, + { key: "negative", label: "Negative", value: -5 }, + { key: "text", label: "Text", value: "n/a" }, + { key: "empty", label: "Empty", value: null }, + ]); + expect(result!.total).toBe(10); + expect(result!.segments.map((s) => s.key)).toEqual(["a"]); + }); + + test("returns null when nothing positive is left to show", () => { + expect(buildDistributionSegments([])).toBeNull(); + expect(buildDistributionSegments([{ key: "a", label: "A", value: 0 }])).toBeNull(); + expect(buildDistributionSegments([{ key: "a", label: "A", value: "text" }])).toBeNull(); + }); + + test("keeps semantic colors and hands the palette only to the rest", () => { + const result = buildDistributionSegments([ + { key: "positive", label: "Positive", value: 5, color: CHART_SENTIMENT_COLORS.positive }, + { key: "other", label: "Other", value: 5 }, + { key: "another", label: "Another", value: 5 }, + ]); + expect(result!.segments.map((s) => s.color)).toEqual([ + CHART_SENTIMENT_COLORS.positive, + CHART_MEASURE_COLORS[0], + CHART_MEASURE_COLORS[1], + ]); + }); + }); + describe("preparePieData", () => { test("returns null for empty or no valid numeric data", () => { expect(preparePieData([], "count")).toBeNull(); @@ -331,3 +407,49 @@ describe("chart-utils", () => { }); }); }); + +describe("flipped bar axis sizing", () => { + test("sizes the category gutter to the labels present", () => { + // Three numeric categories used to leave ~150px of empty gutter before the bars started. + expect(getCategoryAxisWidth(["3", "10", "25"])).toBeLessThan(CATEGORY_AXIS_MAX_WIDTH / 2); + }); + + test("never drops below the floor or above the ceiling", () => { + expect(getCategoryAxisWidth(["1"])).toBe(CATEGORY_AXIS_MIN_WIDTH); + expect(getCategoryAxisWidth([])).toBe(CATEGORY_AXIS_MIN_WIDTH); + expect(getCategoryAxisWidth(["How satisfied are you with the checkout experience overall?"])).toBe( + CATEGORY_AXIS_MAX_WIDTH + ); + }); + + test("takes the longest label, not the first or last", () => { + const width = getCategoryAxisWidth(["ok", "a considerably longer label", "no"]); + expect(width).toBe(getCategoryAxisWidth(["a considerably longer label"])); + }); + + test("reserves room for the widest value label so the longest bar keeps its number", () => { + // The bug this guards: with no padding the label of a bar reaching the axis bound is anchored + // at the plot edge and clipped by the SVG viewport. + expect(getValueLabelPadding(["50", "20", "5"])).toBeGreaterThanOrEqual(VALUE_LABEL_MIN_PADDING); + expect(getValueLabelPadding(["1,234,567"])).toBeGreaterThan(getValueLabelPadding(["5"])); + }); + + test("caps the value gutter so a huge number cannot eat the plot", () => { + expect(getValueLabelPadding(["123,456,789,012,345"])).toBe(VALUE_LABEL_MAX_PADDING); + }); +}); + +describe("formatPercentShare", () => { + test("keeps one fraction digit, so a small real share is not rounded away to 0%", () => { + // 2 records out of 500: the section is drawn and hoverable, so its label must not read "0%". + expect(formatPercentShare(0.004, "en-US")).toBe("0.4%"); + expect(formatPercentShare(1 / 3, "en-US")).toBe("33.3%"); + expect(formatPercentShare(1, "en-US")).toBe("100.0%"); + }); + + test("follows the locale's decimal separator instead of a hardcoded period", () => { + const de = formatPercentShare(0.125, "de-DE"); + expect(de).toContain("12,5"); + expect(de).not.toContain("12.5"); + }); +}); diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts b/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts index 5a8ca3193cbb..0fd8335abb9d 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts @@ -43,8 +43,7 @@ export const CHART_NOT_ENRICHED_COLOR = "#a3a3a3"; // neutral-400 * yellow; positive is the brand teal and very positive the next-darker brand step * (--color-brandnew in globals.css). Validated with the dataviz palette script on white: lightness * band and adjacent-pair CVD separation pass (worst adjacent ΔE 16.2, deutan); the dark brand teal - * sits just under the categorical chroma floor, acceptable for a brand hue. Groundwork for the - * sentiment-only chart (ENG-1558). + * sits just under the categorical chroma floor, acceptable for a brand hue. */ export const CHART_SENTIMENT_COLORS: Record = { very_negative: "#e34948", // red (palette red — sadness) @@ -81,7 +80,7 @@ export const resolveChartType = (raw: string): TChartType => { return parsed.success ? parsed.data : "bar"; }; -const isNumericValue = (val: TChartDataRow[string]): boolean => { +const isNumericValue = (val: unknown): boolean => { if (val === null || val === undefined || val === "") return false; const num = Number(val); return !Number.isNaN(num) && Number.isFinite(num); @@ -144,6 +143,82 @@ export const prepareMeasureSliceData = ( tooltipLabel: labelFor(key), })); +/** + * Format a 0-1 share as a percentage for display, in the app's active language. One fraction digit + * throughout: whole percents print a real 0.4% group as "0%" and make three equal groups add up to + * 99%, and the pie's slice labels and the breakdown bar's legend must agree to the digit, since + * they are two displays of one chart. + * + * `Intl` rather than `toFixed` so the decimal separator and the percent sign follow the locale + * ("12,5 %" in de-DE), which a hardcoded "%" suffix cannot do. + */ +export const formatPercentShare = (percent: number, locale?: string): string => + new Intl.NumberFormat(locale, { + style: "percent", + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }).format(percent); + +/** One section of the single-bar distribution chart (a pie chart's "Breakdown bars" display). */ +export interface TDistributionSegment { + /** Stable react key: the dimension value or the measure id the segment came from. */ + key: string; + label: string; + value: number; + /** Share of the total, 0-1. */ + percent: number; + color: string; +} + +/** Input to {@link buildDistributionSegments}: one candidate section, color optional. */ +export interface TDistributionEntry { + key: string; + label: string; + value: unknown; + /** Meaning-bound color (sentiment scale, "not enriched" gray); palette color when absent. */ + color?: string; +} + +/** + * Turn labelled values into the sections of a single 100% bar: coerce to numbers, compute each + * section's share, and hand out palette colors to the entries that carry no semantic color (so a + * semantic bucket never consumes a categorical hue, as in preparePieData). + * + * Zero and negative entries are dropped: they would render as a zero-width, unhoverable section. + * Sections are ordered largest share first, the order and therefore the palette handout + * preparePieData uses, so switching a pie between its two displays doesn't move or recolour a + * group. Sorting is stable, so equal shares keep the caller's order. Returns null when nothing is + * left to show, i.e. the total is not positive. + */ +export function buildDistributionSegments( + entries: readonly TDistributionEntry[] +): { segments: TDistributionSegment[]; total: number } | null { + let paletteIndex = 0; + const scaled = entries + .map((entry) => ({ + key: entry.key, + label: entry.label, + value: isNumericValue(entry.value) ? Number(entry.value) : 0, + color: entry.color, + })) + .filter((entry) => entry.value > 0) + .sort((a, b) => b.value - a.value); + + const total = scaled.reduce((sum, entry) => sum + entry.value, 0); + if (total <= 0) return null; + + const segments = scaled.map(({ key, label, value, color }) => { + let resolvedColor = color; + if (!resolvedColor) { + resolvedColor = CHART_MEASURE_COLORS[paletteIndex % CHART_MEASURE_COLORS.length]; + paletteIndex++; + } + return { key, label, value, percent: value / total, color: resolvedColor }; + }); + + return { segments, total }; +} + /** Category key for rows produced by {@link pivotMeasuresToCategories}. */ export const PIVOTED_MEASURE_KEY = "measure"; /** Value key for rows produced by {@link pivotMeasuresToCategories}. */ @@ -222,3 +297,47 @@ export function formatCellValue(value: unknown): string { if (typeof value === "boolean" || typeof value === "bigint") return String(value); return ""; } + +// ── Flipped (horizontal) bar chart axis sizing ──────────────────────────────── +// Both of these size a gutter to the text that will actually sit in it, rather than claiming a flat +// maximum: a flat gutter reads as a broken layout when the labels are short (three numeric +// categories left ~150px of empty space before the bars started). + +/** Approximate advance width (px) of one character at `text-xs`. Errs wide on purpose: + * over-estimating leaves a little slack, under-estimating clips or wraps text that had room. */ +const AXIS_CHAR_WIDTH = 6.5; +/** Gap (px) between a tick's text and the axis line. */ +const AXIS_TICK_GAP = 8; + +/** Ceiling (px) for the category gutter: wide enough for a short question label, capped so the bars + * keep most of the plot. Longer labels wrap inside it. */ +export const CATEGORY_AXIS_MAX_WIDTH = 160; +/** Floor (px), so a one-character label still has a readable gutter. */ +export const CATEGORY_AXIS_MIN_WIDTH = 28; + +/** Width (px) for the left-hand category gutter of a flipped bar chart, from the labels present. */ +export const getCategoryAxisWidth = (labels: string[]): number => { + const longest = labels.reduce((max, label) => Math.max(max, label.length), 0); + const needed = Math.ceil(longest * AXIS_CHAR_WIDTH) + AXIS_TICK_GAP * 2; + return Math.min(CATEGORY_AXIS_MAX_WIDTH, Math.max(CATEGORY_AXIS_MIN_WIDTH, needed)); +}; + +/** Ceiling (px) for the value-label gutter — enough for a grouped number like "1,234,567". */ +export const VALUE_LABEL_MAX_PADDING = 72; +/** Floor (px): a single digit still needs the label to clear the bar's end. */ +export const VALUE_LABEL_MIN_PADDING = 14; + +/** + * Room (px) to reserve past the end of the value axis on a flipped bar chart, so the label of the + * longest bar stays inside the SVG. + * + * A vertical chart gets this from the y-axis `padding.top`; flipped, the label moves to the right of + * the bar's end with nothing holding space for it. Whenever the data max lands exactly on the axis + * bound — which the "nice" scale produces routinely, since 10/20/50/100 are all multiples of their + * step — the label of the biggest bar, the one read first, was clipped away entirely. + */ +export const getValueLabelPadding = (labels: string[]): number => { + const longest = labels.reduce((max, label) => Math.max(max, label.length), 0); + const needed = Math.ceil(longest * AXIS_CHAR_WIDTH) + AXIS_TICK_GAP; + return Math.min(VALUE_LABEL_MAX_PADDING, Math.max(VALUE_LABEL_MIN_PADDING, needed)); +}; diff --git a/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx b/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx index 06b6c8782d16..995d182d1edf 100644 --- a/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx +++ b/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx @@ -139,7 +139,12 @@ const MemoizedWidgetContent = memo(function WidgetContent({ if (widget.chart && dataPromise) { return ( }> - + ); } diff --git a/apps/web/modules/ee/analysis/dashboards/components/dashboard-widget-data.tsx b/apps/web/modules/ee/analysis/dashboards/components/dashboard-widget-data.tsx index af21c28343d9..7ffc94e83f66 100644 --- a/apps/web/modules/ee/analysis/dashboards/components/dashboard-widget-data.tsx +++ b/apps/web/modules/ee/analysis/dashboards/components/dashboard-widget-data.tsx @@ -2,7 +2,7 @@ import { use } from "react"; import { useTranslation } from "react-i18next"; -import { TChartQuery } from "@formbricks/types/analysis"; +import { TChartConfig, TChartQuery } from "@formbricks/types/analysis"; import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer"; import { DataViewer } from "@/modules/ee/analysis/charts/components/data-viewer"; import { DEFAULT_WIDGET_VIEW, type TWidgetView } from "@/modules/ee/analysis/dashboards/lib/widget-view"; @@ -15,12 +15,15 @@ interface DashboardWidgetDataProps { | { error: TDashboardWidgetError } >; chartType: TChartType; + /** Saved display settings of the chart behind this widget. */ + config?: TChartConfig; view?: TWidgetView; } export function DashboardWidgetData({ dataPromise, chartType, + config, view = DEFAULT_WIDGET_VIEW, }: Readonly) { const { t } = useTranslation(); @@ -49,6 +52,7 @@ export function DashboardWidgetData({ data={result.data} query={result.query} optionLabels={result.optionLabels} + config={config} /> ); } diff --git a/apps/web/modules/ee/sso/components/azure-button.tsx b/apps/web/modules/ee/sso/components/azure-button.tsx index 935c5de3a08e..30c511d9384a 100644 --- a/apps/web/modules/ee/sso/components/azure-button.tsx +++ b/apps/web/modules/ee/sso/components/azure-button.tsx @@ -30,8 +30,12 @@ export const AzureButton = ({ } const returnToUrlWithSource = getSsoReturnToUrl(returnToUrl, source); - await authClient.signIn.oauth2({ - providerId: "azuread", + // Better Auth 1.7 rebuilt genericOAuth onto the built-in social path (ENG-2343), so + // signIn.oauth2({ providerId }) became signIn.social({ provider }). The callback URL is + // NOT affected: better-auth-providers.ts pins `redirectURI` to /api/auth/oauth2/callback/azuread, + // the URL already registered at every customer IdP, and legacy-sso-callback.ts serves it. + await authClient.signIn.social({ + provider: "azuread", callbackURL: returnToUrlWithSource, // OAuth failures redirect here so the login page's existing ?error= UX surfaces them (parity). errorCallbackURL: "/auth/login", diff --git a/apps/web/modules/ee/sso/components/open-id-button.tsx b/apps/web/modules/ee/sso/components/open-id-button.tsx index 618c428ca2c5..ce708dfb8b14 100644 --- a/apps/web/modules/ee/sso/components/open-id-button.tsx +++ b/apps/web/modules/ee/sso/components/open-id-button.tsx @@ -31,8 +31,12 @@ export const OpenIdButton = ({ } const returnToUrlWithSource = getSsoReturnToUrl(returnToUrl, source); - await authClient.signIn.oauth2({ - providerId: "openid", + // Better Auth 1.7 rebuilt genericOAuth onto the built-in social path (ENG-2343), so + // signIn.oauth2({ providerId }) became signIn.social({ provider }). The callback URL is + // NOT affected: better-auth-providers.ts pins `redirectURI` to /api/auth/oauth2/callback/openid, + // the URL already registered at every customer IdP, and legacy-sso-callback.ts serves it. + await authClient.signIn.social({ + provider: "openid", callbackURL: returnToUrlWithSource, // OAuth failures redirect here so the login page's existing ?error= UX surfaces them (parity). errorCallbackURL: "/auth/login", diff --git a/apps/web/modules/ee/sso/components/saml-button.tsx b/apps/web/modules/ee/sso/components/saml-button.tsx index 51d52f3716d4..0d5b1c972a0e 100644 --- a/apps/web/modules/ee/sso/components/saml-button.tsx +++ b/apps/web/modules/ee/sso/components/saml-button.tsx @@ -36,8 +36,12 @@ export const SamlButton = ({ returnToUrl, lastUsed, source }: Readonly { type: "oauth" as const, provider: "google", providerAccountId: "provider-account-1", + issuer: "local:oauth:google", access_token: "access-token", refresh_token: "refresh-token", scope: "openid email profile", @@ -100,6 +101,10 @@ describe("syncSsoIdentityForUser", () => { id: "account_1", }, data: { + // `issuer` on the token-refresh branch too (ENG-2343): the canonical row may predate the + // backfill window, and 1.7's account lookup filters on `(issuer, accountId)` — so leaving it + // NULL here would keep a recovered link invisible and re-trigger recovery on the next sign-in. + issuer: "local:oauth:google", access_token: "access-token", refresh_token: "refresh-token", scope: "openid email profile", @@ -133,6 +138,7 @@ describe("syncSsoIdentityForUser", () => { type: "oauth", provider: "google", providerAccountId: "provider-account-1", + issuer: "local:oauth:google", access_token: "access-token", refresh_token: "refresh-token", scope: "openid email profile", @@ -216,6 +222,7 @@ describe("syncSsoIdentityForUser", () => { type: "oauth", provider: "google", providerAccountId: "provider-account-1", + issuer: "local:oauth:google", access_token: "access-token", refresh_token: "refresh-token", expires_at: 1234, diff --git a/apps/web/modules/ee/sso/lib/account-linking.ts b/apps/web/modules/ee/sso/lib/account-linking.ts index 274a12b5875e..bac495bf7d8e 100644 --- a/apps/web/modules/ee/sso/lib/account-linking.ts +++ b/apps/web/modules/ee/sso/lib/account-linking.ts @@ -1,7 +1,7 @@ import { prisma } from "@formbricks/database"; import type { IdentityProvider, Prisma } from "@formbricks/database/prisma"; import type { Account } from "@formbricks/types/auth"; -import { OAUTH_ACCOUNT_NOT_LINKED_ERROR } from "@/modules/ee/sso/lib/constants"; +import { OAUTH_ACCOUNT_NOT_LINKED_ERROR, ssoAccountIssuer } from "@/modules/ee/sso/lib/constants"; export const LINKED_SSO_LOOKUP_SELECT = { id: true, @@ -97,7 +97,9 @@ const syncSsoIdentityForUserWithTx = async ({ where: { id: existingCanonicalAccount.id, }, - data: getAccountTokenUpdate(account), + // `issuer` too: the canonical row may predate the ENG-2343 backfill window, and leaving it NULL + // here would keep the recovered link invisible to 1.7's account lookup. + data: { issuer: ssoAccountIssuer(provider), ...getAccountTokenUpdate(account) }, }); } else { await tx.account.update({ @@ -109,6 +111,9 @@ const syncSsoIdentityForUserWithTx = async ({ type: account.type, provider, providerAccountId: account.providerAccountId, + // Same reason as the create branch below: normalising a legacy row without setting `issuer` + // leaves it unmatched by 1.7's account lookup (ENG-2343). + issuer: ssoAccountIssuer(provider), ...getAccountTokenUpdate(account), }, }); @@ -127,6 +132,13 @@ const syncSsoIdentityForUserWithTx = async ({ type: account.type, provider, providerAccountId: account.providerAccountId, + // 1.7 keys the account on `(issuer, accountId)` and `findAccountByKey` filters on `issuer`, so a + // row written without one is invisible to every later sign-in: the user completes + // verify-before-link, gets a session, and is then pushed back through recovery on the NEXT + // sign-in because `NULL !== 'local:oauth:'`. The migration cannot save them either — + // it runs once, before this row exists. Same value as the provider config and the backfill + // (ENG-2343); imported rather than re-spelled so the three cannot drift. + issuer: ssoAccountIssuer(provider), ...getAccountTokenUpdate(account), }, }); diff --git a/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts b/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts index 5e1c948373ac..17b41b53312a 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-hooks.test.ts @@ -72,7 +72,9 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsSamlSsoEnabled: vi.fn(), })); -const callbackCtx = { path: "/oauth2/callback/:providerId", params: { providerId: "openid" } }; +// Better Auth's INTERNAL endpoint path, which 1.7 serves at `/callback/:id`. Not the public SSO callback +// URL — that stays `/api/auth/oauth2/callback/{providerId}`, pinned (ENG-2343) and mapped onto this one. +const callbackCtx = { path: "/callback/:providerId", params: { providerId: "openid" } }; const provisionDecision = { action: "provision" as const, organizationId: "org-1", @@ -101,7 +103,7 @@ beforeEach(() => { describe("getSsoProviderFromContext", () => { test("reads the provider from a generic-OAuth callback's params", () => { expect( - getSsoProviderFromContext({ path: "/oauth2/callback/:providerId", params: { providerId: "openid" } }) + getSsoProviderFromContext({ path: "/callback/:providerId", params: { providerId: "openid" } }) ).toBe("openid"); }); @@ -110,7 +112,7 @@ describe("getSsoProviderFromContext", () => { }); test("falls back to parsing a resolved callback path", () => { - expect(getSsoProviderFromContext({ path: "/oauth2/callback/azuread", params: {} })).toBe("azuread"); + expect(getSsoProviderFromContext({ path: "/callback/azuread", params: {} })).toBe("azuread"); }); test.each([{ path: "/sign-up/email" }, { path: "/sign-in/email" }, {}, null, undefined])( @@ -379,7 +381,7 @@ describe("ssoDatabaseHooks.account.create.after", () => { }); describe("ssoLicenseGateBefore", () => { - const samlCtx = { path: "/oauth2/callback/:providerId", params: { providerId: "saml" } }; + const samlCtx = { path: "/callback/:providerId", params: { providerId: "saml" } }; test("ignores non-callback requests without checking the license", async () => { await ssoLicenseGateBefore({ path: "/sign-up/email" } as never); @@ -409,7 +411,7 @@ describe("ssoLicenseGateBefore", () => { describe("ssoRecoveryAfter", () => { const collisionLocation = "https://app.test/error?error=account_not_linked"; const makeCtx = (overrides: Record = {}) => ({ - path: "/oauth2/callback/:providerId", + path: "/callback/:providerId", params: { providerId: "openid" }, context: { responseHeaders: new Headers({ location: collisionLocation }) }, redirect: vi.fn((url: string) => new Error(`redirect:${url}`)), @@ -487,7 +489,7 @@ describe("ssoRecoveryAfter", () => { describe("blockedSignupDomainRedirectAfter", () => { const makeCtx = (overrides: Record = {}) => ({ - path: "/oauth2/callback/:providerId", + path: "/callback/:providerId", params: { providerId: "openid" }, context: { responseHeaders: new Headers({ location: "https://app.test/auth/login?error=unable_to_create_user" }), diff --git a/apps/web/modules/ee/sso/lib/better-auth-hooks.ts b/apps/web/modules/ee/sso/lib/better-auth-hooks.ts index d57f911f5ebf..6e350d61b795 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-hooks.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-hooks.ts @@ -30,9 +30,10 @@ import { /** * Resolve the SSO provider id from a Better Auth callback endpoint context, else null. * - * Better Auth sets `context.path` to the ROUTE PATTERN — `/oauth2/callback/:providerId` for the - * generic-OAuth plugin, `/callback/:id` for built-in social — and the matched provider on - * `context.params` (`providerId` or `id`). We prefer the parsed param and fall back to parsing a + * Better Auth sets `context.path` to the ROUTE PATTERN — `/callback/:id` for both built-in social + * providers and, since Better Auth 1.7 (ENG-2343), the `genericOAuth` plugin too (it now shares the + * social-provider route instead of its own `/oauth2/callback/:providerId`) — and the matched provider + * on `context.params` (`providerId` or `id`). We prefer the parsed param and fall back to parsing a * resolved path. Returns null for non-callback paths (e.g. `/sign-up/email`) so the hooks below * only act on SSO sign-ups. */ diff --git a/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts b/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts index 665202cffe16..585423be38d6 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts @@ -1,10 +1,20 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { PINNED_SSO_PROVIDER_IDS } from "@/modules/auth/lib/legacy-sso-callback"; // captureSsoIdentity is request-scoped (server-only AsyncLocalStorage); stub it so the mappers run in // isolation and we can assert the identity each provider captures. const { captureSsoIdentity } = vi.hoisted(() => ({ captureSsoIdentity: vi.fn() })); vi.mock("./sso-request-context", () => ({ captureSsoIdentity })); +// The pinned SSO callback URL is built from `getAuthIssuerUrl()`, which reads `@/lib/env` directly rather +// than the constants mocked below — it has to, because that helper encodes Better Auth's own base-URL +// precedence (`BETTER_AUTH_URL ?? NEXTAUTH_URL ?? WEBAPP_URL`). Spread the real env so `@/lib/constants` +// still validates, and pin only the auth URL so the expected callback URL is deterministic. +vi.mock("@/lib/env", async () => { + const actual = await vi.importActual<{ env: Record }>("@/lib/env"); + return { env: { ...actual.env, BETTER_AUTH_URL: "https://app.formbricks.test" } }; +}); + // The module computes ssoSocialProviders / ssoGenericOAuthConfig at IMPORT time from `@/lib/constants`, // so each scenario re-mocks the constants and re-imports. We spread the REAL module so the two hardcoded // SAML literals (SAML_TENANT/SAML_PRODUCT) keep their real values — they are not env-derived toggles and @@ -168,18 +178,149 @@ describe("better-auth SSO providers", () => { "https://login.microsoftonline.com/tenant-123/v2.0/.well-known/openid-configuration" ); expect(azure.scopes).toEqual(["openid", "email", "profile"]); - // ENG-1800: Entra never returns the RFC 9207 response `iss`, so validation must stay off even - // with a fixed tenant — turning it on here is exactly what caused `error=issuer_missing`. - expect(azure.requireIssuerValidation).toBe(false); + // ENG-1800 no longer needs an opt-out: Better Auth 1.7 only compares the RFC 9207 response + // `iss` when the provider actually sends one, and Entra never does, so the check that produced + // `error=issuer_missing` cannot fire. What replaces it as the load-bearing invariant is the + // pinned account issuer below. + expect(azure).not.toHaveProperty("requireIssuerValidation"); + expect(azure.accountIssuer).toBe("local:oauth:azuread"); + }); + + /** + * The account-identity contract with the database migration (ENG-2343). + * + * Better Auth 1.7 keys accounts on (issuer, accountId). Left unpinned, a provider with a + * discoveryUrl adopts the DISCOVERED issuer — tenant-specific, so different on every install and + * impossible to reproduce in a portable backfill. These values must stay byte-identical to what + * migration 20260812110000 writes into Account.issuer, or existing SSO users stop matching at + * sign-in and are pushed into account recovery. + */ + test("pins a portable account issuer on every generic provider", async () => { + const m = await loadProviders({ + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + OIDC_OAUTH_ENABLED: true, + SAML_OAUTH_ENABLED: true, + }); + + expect(m.ssoGenericOAuthConfig.map((c) => [c.providerId, c.accountIssuer])).toEqual([ + ["azuread", "local:oauth:azuread"], + ["openid", "local:oauth:openid"], + ["saml", "local:oauth:saml"], + ]); }); - test("Azure discovery URL falls back to the 'common' tenant when none is configured", async () => { + /** + * The callback URL is a registered value at every customer IdP, and OAuth requires it to match + * EXACTLY (RFC 6749 §3.1.2.2, no wildcards) — so letting it track Better Auth's routing means every + * self-hoster edits every IdP whenever upstream moves the route. It has already moved twice: the 1.6 + * genericOAuth plugin mounted `/oauth2/callback/:providerId`, and 1.7 rebuilt the plugin onto the + * built-in `/callback/:id`. This pins the v5.2 URL that is already registered everywhere. + * + * The URL alone is not enough — see legacy-sso-callback.ts for the half that serves it, and + * better-auth-redirect-uri-pin.test.ts for the guard that Better Auth still honours the option. + */ + test("pins the v5.2 callback URL on every generic provider", async () => { + const m = await loadProviders({ + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + OIDC_OAUTH_ENABLED: true, + SAML_OAUTH_ENABLED: true, + }); + + expect(m.ssoGenericOAuthConfig.map((c) => [c.providerId, c.redirectURI])).toEqual([ + ["azuread", "https://app.formbricks.test/api/auth/oauth2/callback/azuread"], + ["openid", "https://app.formbricks.test/api/auth/oauth2/callback/openid"], + ["saml", "https://app.formbricks.test/api/auth/oauth2/callback/saml"], + ]); + }); + + /** + * Drift guard for the two halves of the pin. `legacy-sso-callback.ts` keeps its own id literal + * because it must work on an unlicensed instance, where this config list is empty — so nothing but + * this assertion stops the two from diverging. A provider pinned here but missing there advertises a + * URL no route serves: a 404 on every sign-in with that provider. + */ + test("every pinned provider is one the legacy callback route serves", async () => { + const m = await loadProviders({ + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + OIDC_OAUTH_ENABLED: true, + SAML_OAUTH_ENABLED: true, + }); + + const pinned = m.ssoGenericOAuthConfig + .filter((c) => c.redirectURI?.includes("/api/auth/oauth2/callback/")) + .map((c) => c.providerId); + + expect(pinned).toEqual([...PINNED_SSO_PROVIDER_IDS]); + }); + + /** + * The multi-tenant case must NOT use discovery (ENG-2343). Microsoft's `common` discovery document + * advertises `issuer: "https://login.microsoftonline.com/{tenantid}/v2.0"` — a documented template, + * verified against the live endpoint — and Better Auth 1.7 compares `iss` for literal equality + * whenever discovery yields both `issuer` and `jwks_uri`. Every real id_token carries the tenant + * GUID, so discovery here would reject every Azure sign-in. Explicit endpoints build no + * `idTokenConfig` at all, which restores the 1.6 UserInfo path. + */ + /** + * Identity derivation must not depend on whether discovery ran (ENG-2343). Better Auth's default is + * `isOidc ? profile.sub : profile.id`, and `isOidc` is set only inside the discovery branch — so the + * Azure `common` path and the SAML bridge, which both configure endpoints explicitly, would fall to + * `profile.id`. Microsoft Graph `/oidc/userinfo` returns only `sub`, which yielded an empty subject + * and failed the callback with `unable_to_get_user_info`. BoxyHQ genuinely returns `id`, so the two + * differ and both have to be pinned rather than inferred. + */ + test.each([ + ["azuread", { sub: "az-sub", id: "wrong" }, "az-sub"], + ["openid", { sub: "oidc-sub", id: "wrong" }, "oidc-sub"], + ["saml", { id: "saml-id", sub: "wrong" }, "saml-id"], + ])( + "%s derives its account subject from the field that provider actually sends", + async (providerId, profile, expected) => { + const m = await loadProviders({ + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + OIDC_OAUTH_ENABLED: true, + SAML_OAUTH_ENABLED: true, + }); + const provider = m.ssoGenericOAuthConfig.find((c) => c.providerId === providerId); + + expect(provider?.accountSubject).toBeDefined(); + expect(provider?.accountSubject?.({ profile } as never)).toBe(expected); + } + ); + + test("Azure uses explicit endpoints, not discovery, when no tenant is configured", async () => { const m = await loadProviders({ ENTERPRISE_LICENSE_KEY: "lic", AZURE_OAUTH_ENABLED: true }); const azure = m.ssoGenericOAuthConfig.find((c) => c.providerId === "azuread"); + + expect(azure?.discoveryUrl).toBeUndefined(); + expect(azure?.authorizationUrl).toBe("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"); + expect(azure?.tokenUrl).toBe("https://login.microsoftonline.com/common/oauth2/v2.0/token"); + expect(azure?.userInfoUrl).toBe("https://graph.microsoft.com/oidc/userinfo"); + expect(azure?.accountIssuer).toBe("local:oauth:azuread"); + }); + + /** + * The single-tenant case keeps discovery, so the id_token IS verified against a concrete issuer — + * strictly stronger than 1.6. This pairing is the whole point of the split: no self-hoster has to + * change anything, and those who can have the stronger check get it. + */ + test("Azure uses discovery when a concrete tenant is configured", async () => { + const m = await loadProviders({ + ENTERPRISE_LICENSE_KEY: "lic", + AZURE_OAUTH_ENABLED: true, + AZUREAD_TENANT_ID: "00000000-1111-2222-3333-444444444444", + }); + const azure = m.ssoGenericOAuthConfig.find((c) => c.providerId === "azuread"); + expect(azure?.discoveryUrl).toBe( - "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration" + "https://login.microsoftonline.com/00000000-1111-2222-3333-444444444444/v2.0/.well-known/openid-configuration" ); - expect(azure?.requireIssuerValidation).toBe(false); + expect(azure?.authorizationUrl).toBeUndefined(); + expect(azure?.tokenUrl).toBeUndefined(); }); test("Azure mapProfileToUser resolves the display name through its fallback chain", async () => { @@ -220,7 +361,8 @@ describe("better-auth SSO providers", () => { clientId: "oidc-id", clientSecret: "oidc-secret", pkce: true, - requireIssuerValidation: true, + // Issuer validation is automatic in 1.7 for providers that return `iss`, so the flag is gone. + accountIssuer: "local:oauth:openid", }); expect(oidc.discoveryUrl).toBe("https://idp.test/.well-known/openid-configuration"); expect( diff --git a/apps/web/modules/ee/sso/lib/better-auth-providers.ts b/apps/web/modules/ee/sso/lib/better-auth-providers.ts index d650fd0d26e9..b66467df4477 100644 --- a/apps/web/modules/ee/sso/lib/better-auth-providers.ts +++ b/apps/web/modules/ee/sso/lib/better-auth-providers.ts @@ -1,6 +1,6 @@ import "server-only"; import type { BetterAuthOptions } from "better-auth"; -import type { GenericOAuthConfig } from "better-auth/plugins"; +import type { GenericOAuthConfig, GenericOAuthUserInfo } from "better-auth/plugins"; import { AZUREAD_CLIENT_ID, AZUREAD_CLIENT_SECRET, @@ -22,6 +22,8 @@ import { SAML_TENANT, WEBAPP_URL, } from "@/lib/constants"; +import { getAuthIssuerUrl } from "@/modules/auth/lib/oauth-urls"; +import { ssoAccountIssuer } from "./constants"; import { captureSsoIdentity } from "./sso-request-context"; // Better Auth's per-provider profile types, extracted so the social mappers below aren't implicitly @@ -38,18 +40,20 @@ type GoogleProfile = Parameters["mapProfileTo /** * Better Auth SSO providers (ENG-1054), mirroring the NextAuth set in `./providers.ts`. Gated behind * `ENTERPRISE_LICENSE_KEY` (parity with the `getSSOProviders()` gate) and each provider's configured - * credentials. Google/GitHub use Better Auth's built-in social providers; Azure/OIDC/SAML use the - * generic-OAuth plugin (Azure keeps providerId "azuread" so existing `account.provider` rows need no - * remap — design doc D6). + * credentials. Google/GitHub use Better Auth's built-in social providers; Azure/OIDC/SAML register + * through the `genericOAuth` plugin (Azure keeps providerId "azuread" so existing `account.provider` + * rows need no remap — design doc D6). * * IMPORTANT — these objects only REGISTER providers. The hardened account linking / verify-before-link * (SSO recovery) + org-provisioning flow (design doc D7) is re-expressed via Better Auth hooks * SEPARATELY (not here); `account.accountLinking.enabled` is false so nothing auto-links. That hooks * work is the security-sensitive part of Phase 5 and is pending review. * - * ⚠ The generic-OAuth callback path is `/api/auth/oauth2/callback/{providerId}` (differs from - * NextAuth's `/api/auth/callback/{provider}`) — at cutover, the OIDC IdP redirect URIs and the BoxyHQ - * Jackson connection `redirect_uri` must be re-registered to match. + * ⚠ Callback path (ENG-2343): PINNED, and deliberately not the version default. Better Auth has moved + * this path twice with no choice of ours — 1.6's `genericOAuth` plugin mounted its own + * `/oauth2/callback/:providerId` route, and 1.7 rebuilt that plugin onto the built-in `/callback/:id` + * one. Tracking the default makes every self-hoster re-register a redirect URI on each such upstream + * change, so `redirectURI` below holds the v5.2 URL their IdPs already have. See ssoLegacyRedirectUri. */ export const ssoSocialProviders = ENTERPRISE_LICENSE_KEY ? { @@ -61,7 +65,7 @@ export const ssoSocialProviders = ENTERPRISE_LICENSE_KEY // Capture the resolved identity for verify-before-link recovery (design doc §13). // ⚠ providerAccountId must equal Better Auth's account.accountId — validate at cutover. mapProfileToUser: (profile: GithubProfile) => { - captureSsoIdentity({ email: profile.email, providerAccountId: String(profile.id) }); + captureSsoIdentity({ email: profile.email, providerAccountId: toAccountSubject(profile.id) }); return { email: profile.email }; }, }, @@ -82,6 +86,117 @@ export const ssoSocialProviders = ENTERPRISE_LICENSE_KEY } : {}; +/** + * Coerce a provider subject to a string WITHOUT inventing one. + * + * Better Auth 1.7 types `sub`/`id` as `string | number`, so a bare `String(...)` is tempting — but it + * turns a missing subject into the literal "undefined", which is truthy. `captureSsoIdentity` + * deliberately drops an identity whose providerAccountId is falsy, precisely so a provider that omits + * it cannot drive account recovery and link the wrong account. Passing "undefined" would sail past + * that guard. + */ +const toAccountSubject = (subject: string | number | null | undefined): string | undefined => + subject === null || subject === undefined ? undefined : String(subject); + +/** + * The SSO callback URL every customer IdP has had registered since v5.2, pinned so it stops tracking + * Better Auth's routing (ENG-2343). + * + * `redirectURI` wins over the route-derived value in both places that must agree — the authorization + * request (`@better-auth/core/.../create-authorization-url.mjs`) and the token exchange + * (`.../validate-authorization-code.mjs`), each `options.redirectURI || redirectURI` — so the IdP never + * sees a `redirect_uri` mismatch between the two legs. The option is not new: 1.6 honoured it with the + * same precedence, so pinning is not a 1.7 affordance we might lose on the next minor. + * + * Pinning the URL is only half the job, because `redirectURI` does NOT move the route Better Auth mounts + * its handler on. `apps/web/app/api/auth/[...all]/route.ts` serves this legacy path by mapping it onto + * the path the installed version actually handles — that half is ours and cannot be removed upstream. + * This half rests on an upstream option, so better-auth-redirect-uri-pin.test.ts asserts the + * `redirect_uri` Better Auth really emits and fails the build if it is ever ignored. + * + * Built off `getAuthIssuerUrl()` rather than `WEBAPP_URL`, deliberately: this URL is where the identity + * provider delivers the authorization code, so it must name the same origin Better Auth itself considers + * its base — `env.BETTER_AUTH_URL ?? env.NEXTAUTH_URL` (auth.ts), with WEBAPP_URL only as the last + * fallback, which is exactly the precedence `getAuthIssuerUrl` encodes. Deriving it from WEBAPP_URL alone + * would let the two diverge: the code would arrive at a host whose signed state cookie was never set, so + * sign-in fails closed with a state mismatch. `appendPath` also handles the documented subpath shape where + * the configured auth URL already ends in `/api/auth` (see ENG-606). + */ +const ssoLegacyRedirectUri = (providerId: string): string => + `${getAuthIssuerUrl()}/oauth2/callback/${providerId}`; + +/** OIDC display name: `name`, else given+family, else `preferred_username`. */ +const toDisplayName = (profile: GenericOAuthUserInfo): string | undefined => { + const parts = [profile.given_name, profile.family_name].filter(Boolean).join(" "); + const name = profile.name || parts || profile.preferred_username; + return typeof name === "string" && name.length > 0 ? name : undefined; +}; + +/** BoxyHQ userinfo display name: `name`, else firstName + lastName. */ +const toSamlDisplayName = (profile: GenericOAuthUserInfo): string | undefined => { + const parts = [profile.firstName, profile.lastName].filter(Boolean).join(" "); + const name = profile.name || parts; + return typeof name === "string" && name.length > 0 ? name : undefined; +}; + +/** + * Provider account subject, pinned per provider rather than left to Better Auth's default (ENG-2343). + * + * The default is `isOidc ? profile.sub ?? "" : profile.id ?? ""` (`generic-oauth/index.mjs:138`), and + * `isOidc` is only ever set inside the discovery branch (`:103`). So it silently depends on whether + * discovery ran: our Azure `common` path and the SAML bridge both configure endpoints explicitly, which + * leaves `isOidc` false and resolves `profile.id` — correct for BoxyHQ, which returns `id`, and WRONG for + * Microsoft Graph `/oidc/userinfo`, which returns only `sub`. That yields an empty subject and the + * callback fails `OAUTH_ACCOUNT_SUBJECT_INVALID` → `error=unable_to_get_user_info`. + * + * Pinning it on all three makes identity derivation ours and independent of a discovery field, which + * also stops the openid provider silently changing subject source across upgrades. An absent subject + * still fails closed: Better Auth rejects an empty accountId rather than inventing one. + */ +const ssoAccountSubject = + (field: "sub" | "id") => + ({ profile }: { profile: GenericOAuthUserInfo }): string | number => + profile[field] ?? ""; + +/** + * Azure endpoint configuration, split on whether a concrete tenant is configured (ENG-2343). + * + * Better Auth 1.7 verifies the id_token whenever discovery yields both `jwks_uri` and `issuer`, and it + * compares `iss` for **literal** equality. Microsoft's multi-tenant (`common`) discovery document + * advertises `issuer: "https://login.microsoftonline.com/{tenantid}/v2.0"` — a documented TEMPLATE, not + * a value. Microsoft's own guidance is to substitute the token's `tid` and validate that against the + * tenants you accept; a literal comparison is guaranteed to fail, because every real id_token carries + * the tenant GUID. So with `AZUREAD_TENANT_ID` unset (our documented default) 1.7 would reject every + * Azure sign-in. 1.6 never had this problem: its genericOAuth read identity from UserInfo and never + * parsed the id_token at all. + * + * Rather than make `AZUREAD_TENANT_ID` mandatory — which would demand action from every self-hoster who + * has not set it, and drop support for genuinely multi-tenant app registrations, which have no single + * issuer by construction — the tenant decides the mechanism: + * + * - **Concrete tenant**: keep `discoveryUrl`. The discovered issuer is a real value, so the id_token is + * fully verified. Strictly stronger than 1.6. + * - **`common`**: configure the endpoints explicitly and skip discovery, so no `idTokenConfig` is built + * (`generic-oauth/index.mjs` only constructs it inside the discovery branch) and identity comes from + * UserInfo — the 1.6 behaviour, over a client-authenticated back-channel call to Microsoft. Note this + * is not where the code flow's security lives: that is `state` + PKCE and the authenticated code + * exchange, and RFC 9207 mix-up defence still applies via `iss` on the authorization response when a + * provider sends one. + * + * Deliberately NOT setting `requireIdTokenVerification`: on the `common` path it would throw at init and + * take Azure sign-in down, which is the outcome this split exists to avoid. + */ +const azureTenant = AZUREAD_TENANT_ID || "common"; +const azureEndpoints = AZUREAD_TENANT_ID + ? { + discoveryUrl: `https://login.microsoftonline.com/${azureTenant}/v2.0/.well-known/openid-configuration`, + } + : { + authorizationUrl: `https://login.microsoftonline.com/${azureTenant}/oauth2/v2.0/authorize`, + tokenUrl: `https://login.microsoftonline.com/${azureTenant}/oauth2/v2.0/token`, + userInfoUrl: "https://graph.microsoft.com/oidc/userinfo", + }; + export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KEY ? [ ...(AZURE_OAUTH_ENABLED @@ -90,28 +205,29 @@ export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KE providerId: "azuread", clientId: AZUREAD_CLIENT_ID ?? "", clientSecret: AZUREAD_CLIENT_SECRET ?? "", - discoveryUrl: `https://login.microsoftonline.com/${AZUREAD_TENANT_ID || "common"}/v2.0/.well-known/openid-configuration`, + ...azureEndpoints, scopes: ["openid", "email", "profile"], + // Redundant since 1.7 defaults it to true, kept explicit: this is a security control, + // and an explicit value survives a future default flip. pkce: true, - // Must stay false for Azure. Better Auth's issuer check reads the RFC 9207 - // authorization-RESPONSE `iss` query parameter, and Microsoft Entra does not implement - // RFC 9207 — its v2.0 metadata omits `authorization_response_iss_parameter_supported` - // and it never returns that param. So enabling this can only ever fail with - // `error=issuer_missing` (ENG-1800); it can never pass, regardless of tenant. The - // param's purpose (disambiguating which AS responded, to defend against mix-up) is - // already covered here structurally: the per-provider callback path pins the token - // endpoint to Azure's own, and PKCE (above) + state validation bind the exchange. OIDC - // (below) keeps the check on because a spec-compliant provider does return `iss`. - requireIssuerValidation: false, + // `requireIssuerValidation` is gone in 1.7 (ENG-2343) and this no longer needs an + // opt-out. ENG-1800 was that Better Auth rejected a MISSING RFC 9207 `iss` response + // parameter, which Microsoft Entra never sends — so the check could only ever fail. + // 1.7 only compares `iss` when the provider actually returns one + // (`if (iss && provider.issuer && iss !== provider.issuer)`), so Entra short-circuits + // and the mix-up defence still applies to providers that do implement RFC 9207. + accountIssuer: ssoAccountIssuer("azuread"), + accountSubject: ssoAccountSubject("sub"), + redirectURI: ssoLegacyRedirectUri("azuread"), mapProfileToUser: (profile) => { // Capture for verify-before-link recovery; name parity with the OIDC mapping. - captureSsoIdentity({ email: profile.email, providerAccountId: profile.sub }); + captureSsoIdentity({ + email: profile.email, + providerAccountId: toAccountSubject(profile.sub), + }); return { email: profile.email, - name: - profile.name || - [profile.given_name, profile.family_name].filter(Boolean).join(" ") || - profile.preferred_username, + name: toDisplayName(profile), }; }, } satisfies GenericOAuthConfig, @@ -125,17 +241,23 @@ export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KE clientSecret: OIDC_CLIENT_SECRET ?? "", discoveryUrl: `${OIDC_ISSUER}/.well-known/openid-configuration`, scopes: ["openid", "email", "profile"], + // Redundant since 1.7 defaults it to true, kept explicit (see azuread above). pkce: true, - requireIssuerValidation: true, // RFC 9207 mix-up defense (design doc §10.3) + // `requireIssuerValidation: true` (RFC 9207 mix-up defence, design doc §10.3) is gone + // in 1.7 — the comparison is now automatic whenever the provider returns `iss`, so the + // defence is kept without the flag. + accountIssuer: ssoAccountIssuer("openid"), + accountSubject: ssoAccountSubject("sub"), + redirectURI: ssoLegacyRedirectUri("openid"), mapProfileToUser: (profile) => { - captureSsoIdentity({ email: profile.email, providerAccountId: profile.sub }); + captureSsoIdentity({ + email: profile.email, + providerAccountId: toAccountSubject(profile.sub), + }); return { email: profile.email, // Parity with provisionNewSsoUser (OIDC): name → given+family → preferred_username. - name: - profile.name || - [profile.given_name, profile.family_name].filter(Boolean).join(" ") || - profile.preferred_username, + name: toDisplayName(profile), }; }, } satisfies GenericOAuthConfig, @@ -152,15 +274,20 @@ export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KE tokenUrl: `${WEBAPP_URL}/api/auth/saml/token`, userInfoUrl: `${WEBAPP_URL}/api/auth/saml/userinfo`, scopes: [], + // Redundant since 1.7 defaults it to true, kept explicit (see azuread above). pkce: true, + // Already a plain string map, which is all 1.7 accepts here. authorizationUrlParams: { provider: "saml", tenant: SAML_TENANT, product: SAML_PRODUCT }, + accountIssuer: ssoAccountIssuer("saml"), + accountSubject: ssoAccountSubject("id"), + redirectURI: ssoLegacyRedirectUri("saml"), mapProfileToUser: (profile) => { // ⚠ BoxyHQ's userinfo id — validate it matches Better Auth's account.accountId at cutover. - captureSsoIdentity({ email: profile.email, providerAccountId: String(profile.id) }); + captureSsoIdentity({ email: profile.email, providerAccountId: toAccountSubject(profile.id) }); return { email: profile.email, // Parity with provisionNewSsoUser (SAML): name → firstName + lastName. - name: profile.name || [profile.firstName, profile.lastName].filter(Boolean).join(" "), + name: toSamlDisplayName(profile), }; }, } satisfies GenericOAuthConfig, diff --git a/apps/web/modules/ee/sso/lib/constants.ts b/apps/web/modules/ee/sso/lib/constants.ts index d060f5aa339c..c318ec27ca4f 100644 --- a/apps/web/modules/ee/sso/lib/constants.ts +++ b/apps/web/modules/ee/sso/lib/constants.ts @@ -1,2 +1,21 @@ export const OAUTH_ACCOUNT_NOT_LINKED_ERROR = "OAuthAccountNotLinked"; export const SSO_RECOVERY_COMPLETION_PATH = "/api/auth/sso/recovery/complete"; + +/** + * The synthetic `Account.issuer` for our generic-OAuth providers (ENG-2343). + * + * Lives here, in a dependency-free module, because THREE places must produce byte-identical values and + * a literal repeated three times is a silent-divergence bug waiting to happen: + * + * 1. `better-auth-providers.ts` — `accountIssuer`, what Better Auth writes and looks up. + * 2. `account-linking.ts` — the rows SSO recovery writes itself. + * 3. `migration/20260812110000_…` — the backfill, as a SQL literal (`'local:oauth:' || "provider"`), + * which cannot call TypeScript and is therefore the copy this one has to match. + * + * Deliberately NOT `createOAuthAccountIssuer` from `@better-auth/core/db`, even though it is public and + * currently identical: because `accountIssuer` is set explicitly, Better Auth stores and looks up + * whatever we hand it, so upstream's format never enters the picture — while the SQL literal in (3) + * cannot follow an upstream change. Tracking upstream would drift us away from rows already written. + */ +export const ssoAccountIssuer = (providerId: string): string => + `local:oauth:${encodeURIComponent(providerId)}`; diff --git a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.render-parity.integration.test.ts b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.render-parity.integration.test.ts index 74ec14116c17..015acd69ca25 100644 --- a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.render-parity.integration.test.ts +++ b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.render-parity.integration.test.ts @@ -102,6 +102,19 @@ vi.mock("@formbricks/database", () => ({ updateMany: mockWorkflowRunLogUpdateMany, findFirst: mockWorkflowRunLogFindFirst, }, + // Better Auth 1.7's oauthProvider seeds its resources at plugin init (ENG-2343), and this module's + // import graph reaches auth.ts. Seeding against a mock that does not declare `oauthResource` throws + // an unhandled BetterAuthError which fails the run even though every test passes — same stub as the + // unit sibling in process-workflow-run-job.test.ts. + oauthResource: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockImplementation((args: { data: unknown }) => Promise.resolve(args.data)), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + update: vi.fn().mockImplementation((args: { data: unknown }) => Promise.resolve(args.data)), + upsert: vi.fn().mockImplementation((args: { create: unknown }) => Promise.resolve(args.create)), + }, }, })); @@ -112,14 +125,20 @@ vi.mock("@/lib/organization/service", () => ({ })); vi.mock("@/lib/workspace/service", () => ({ getWorkspaceMemberEmails: mockGetWorkspaceMemberEmails })); -vi.mock("@formbricks/logger", () => ({ - logger: { +vi.mock("@formbricks/logger", () => { + const mockLogger = { debug: vi.fn(), error: mockLoggerError, info: mockLoggerInfo, warn: mockLoggerWarn, - }, -})); + // Better Auth 1.7 warns during init() — through our betterAuthLogger, which routes via + // logger.withContext (ENG-2343). `silenceWarnings` used to suppress that warning and was removed + // upstream, so the call is now unavoidable: mirror the real logger's child-logger shape or it + // crashes as an unhandled rejection. + withContext: vi.fn(() => mockLogger), + }; + return { logger: mockLogger }; +}); // --------------------------------------------------------------------------- // Fixtures — a real survey block/element + a completing response with answers, diff --git a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts index 31541fcdab19..0b9ce2353900 100644 --- a/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts +++ b/apps/web/modules/ee/workflows/lib/runner/process-workflow-run-job.test.ts @@ -49,6 +49,18 @@ vi.mock("@formbricks/database", () => ({ updateMany: mockWorkflowRunLogUpdateMany, findFirst: mockWorkflowRunLogFindFirst, }, + // Better Auth 1.7's oauthProvider plugin seeds resources at boot (ENG-2343); this module's import + // graph reaches auth.ts, and a boot-time seed against a model this mock doesn't declare throws an + // unhandled BetterAuthError. + oauthResource: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockImplementation((args: { data: unknown }) => Promise.resolve(args.data)), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + update: vi.fn().mockImplementation((args: { data: unknown }) => Promise.resolve(args.data)), + upsert: vi.fn().mockImplementation((args: { create: unknown }) => Promise.resolve(args.create)), + }, }, })); @@ -96,14 +108,19 @@ vi.mock("@/lib/workspace/service", () => ({ getWorkspaceMemberEmails: mockGetWorkspaceMemberEmails, })); -vi.mock("@formbricks/logger", () => ({ - logger: { +vi.mock("@formbricks/logger", () => { + const mockLogger = { debug: vi.fn(), error: mockLoggerError, info: mockLoggerInfo, warn: mockLoggerWarn, - }, -})); + // Better Auth 1.7 (ENG-2343) warns during init() — via our betterAuthLogger, which calls + // logger.withContext(...) — for the oauthAuthServerConfig discovery warning silenceWarnings used + // to suppress. Mirror the real logger's child-logger shape so that doesn't crash as unhandled. + withContext: vi.fn(() => mockLogger), + }; + return { logger: mockLogger }; +}); const triggerPayload = { type: "response.completed" as const, diff --git a/apps/web/modules/mcp/auth.test.ts b/apps/web/modules/mcp/auth.test.ts index 671adb4d3358..7c9010928669 100644 --- a/apps/web/modules/mcp/auth.test.ts +++ b/apps/web/modules/mcp/auth.test.ts @@ -12,15 +12,15 @@ import { handleAuthenticatedMcpRequest, } from "./auth"; -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, }), })), })); @@ -30,6 +30,19 @@ vi.mock("@formbricks/database", () => ({ user: { findUnique: userFindUniqueMock, }, + // This file's mock replaces the global one in vitestSetup, so it needs its own no-op + // `oauthResource`: Better Auth 1.7 seeds resources when the oauthProvider plugin initialises, + // which importing ./auth triggers, and without it the adapter throws an unhandled + // `Model oauthResource does not exist in the database` alongside a passing suite. + oauthResource: { + findMany: () => Promise.resolve([]), + findFirst: () => Promise.resolve(null), + findUnique: () => Promise.resolve(null), + create: (args: { data: unknown }) => Promise.resolve(args.data), + createMany: () => Promise.resolve({ count: 0 }), + update: (args: { data: unknown }) => Promise.resolve(args.data), + upsert: (args: { create: unknown }) => Promise.resolve(args.create), + }, }, })); @@ -114,7 +127,7 @@ function createRequest(url = "http://localhost/api/mcp", headers: Record { beforeEach(() => { vi.clearAllMocks(); - verifyAccessTokenMock.mockReset(); + verifyBearerTokenMock.mockReset(); userFindUniqueMock.mockResolvedValue({ isActive: true }); vi.mocked(applyRateLimit).mockResolvedValue({ allowed: true }); vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); @@ -288,7 +301,7 @@ describe("authenticateMcpRequest", () => { }); test("authenticates OAuth bearer tokens and rate limits by user and client", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", email: "user@example.com", @@ -319,10 +332,13 @@ describe("authenticateMcpRequest", () => { }); } expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled(); - expect(verifyAccessTokenMock).toHaveBeenCalledWith("oauth_access_token", { + // Exact equality on purpose: this is the canary for the verify options drifting. `typ` pins the + // RFC 9068 access-token type, enforceable from Better Auth 1.7 (1.6 emitted no typ header). + expect(verifyBearerTokenMock).toHaveBeenCalledWith("oauth_access_token", { verifyOptions: { audience: "https://app.example.com/api/mcp", issuer: "https://app.example.com/api/auth", + typ: "at+jwt", }, jwksUrl: "https://app.example.com/api/auth/jwks", }); @@ -337,7 +353,7 @@ describe("authenticateMcpRequest", () => { }); test("rejects OAuth bearer tokens for inactive users", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", azp: "client_1", @@ -376,7 +392,7 @@ describe("authenticateMcpRequest", () => { }); test("rejects OAuth bearer tokens holding no MCP resource scope at all", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", client_id: "client_2", @@ -407,7 +423,7 @@ describe("authenticateMcpRequest", () => { // The inverse of the challenge fix: offline_access is advertised so clients can obtain a refresh // token, but it grants no resource access, so it must never satisfy the baseline gate on its own. test("rejects an OAuth bearer token scoped only to offline_access", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", client_id: "client_2", @@ -432,7 +448,7 @@ describe("authenticateMcpRequest", () => { test.each([["feedbackRecords:read"], ["surveys:write"]])( "authenticates an OAuth token scoped only to %s", async (scope) => { - verifyAccessTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", azp: "client_1", scope }); + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", azp: "client_1", scope }); const result = await authenticateMcpRequest( createRequest("http://localhost/api/mcp", { @@ -452,7 +468,7 @@ describe("authenticateMcpRequest", () => { // provider drops `verifyOptions.audience` from its own verification, at which point this check is // the only thing standing between a foreign-audience token and the MCP tools. test("rejects an OAuth token whose audience omits the MCP resource, independently of jose", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: "https://other.example.com/api", sub: "user_1", azp: "client_1", @@ -474,7 +490,7 @@ describe("authenticateMcpRequest", () => { }); test("returns 429 when audience-rejected tokens exceed the unauthenticated MCP rate limit", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: [MCP_AUDIENCE, "https://other.example.com/api"], sub: "user_1", azp: "client_1", @@ -496,7 +512,7 @@ describe("authenticateMcpRequest", () => { }); test("rejects OAuth bearer tokens without a user subject", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, azp: "client_1", scope: "surveys:read", @@ -519,7 +535,7 @@ describe("authenticateMcpRequest", () => { }); test("rejects invalid OAuth bearer tokens with an OAuth challenge", async () => { - verifyAccessTokenMock.mockRejectedValue(new Error("Invalid token")); + verifyBearerTokenMock.mockRejectedValue(new Error("Invalid token")); const result = await authenticateMcpRequest( createRequest("http://localhost/api/mcp", { @@ -547,7 +563,7 @@ describe("authenticateMcpRequest", () => { }); test("returns 429 when OAuth requests are rate limited", async () => { - verifyAccessTokenMock.mockResolvedValue({ + verifyBearerTokenMock.mockResolvedValue({ aud: MCP_AUDIENCE, sub: "user_1", azp: "client_1", @@ -620,7 +636,7 @@ describe("MCP OAuth access token audience binding", () => { vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); keyPair = await generateKeyPair("ES256"); - verifyAccessTokenMock.mockImplementation( + verifyBearerTokenMock.mockImplementation( async (token: string, opts: { verifyOptions: { audience: string; issuer: string } }) => (await jwtVerify(token, keyPair.publicKey, opts.verifyOptions)).payload ); @@ -628,7 +644,9 @@ describe("MCP OAuth access token audience binding", () => { async function signAccessToken(aud: string | string[] | undefined): Promise { const token = new SignJWT({ scope: "surveys:read", azp: "client_1" }) - .setProtectedHeader({ alg: "ES256" }) + // `typ: at+jwt` is what Better Auth 1.7 stamps on an access token (RFC 9068 §2.1), and the + // resource server now requires it — so the fixtures have to carry it to stay realistic. + .setProtectedHeader({ alg: "ES256", typ: "at+jwt" }) .setIssuer(ISSUER) .setSubject("user_1") .setIssuedAt() diff --git a/apps/web/modules/mcp/auth.ts b/apps/web/modules/mcp/auth.ts index 75386128f8b0..37094a42dc93 100644 --- a/apps/web/modules/mcp/auth.ts +++ b/apps/web/modules/mcp/auth.ts @@ -39,6 +39,12 @@ const QUERY_CREDENTIAL_PARAMS = new Set([ "authorization", ]); +/** + * RFC 9068 §2.1 media type for a JWT access token. Better Auth stamps it into the JWS header from + * 1.7 onwards; 1.6 emitted no `typ` at all. + */ +const JWT_ACCESS_TOKEN_TYPE = "at+jwt"; + const oauthResourceClient = oauthProviderResourceClient(auth); export type TMcpAuthInfo = AuthInfo & { @@ -395,10 +401,31 @@ async function authenticateMcpOAuthBearer( let payload: JWTPayload; try { - payload = await oauthResourceClient.getActions().verifyAccessToken(token, { + // Renamed from `verifyAccessToken` in Better Auth 1.7 (ENG-2343). `hasAcceptedMcpAudience` below is + // kept regardless of what upstream does with `verifyOptions.audience`: an earlier version of this + // comment asserted that 1.7 stops passing it into its own `jwtVerify`, which could not be + // substantiated — `verifyBearerToken` is re-exported through `better-auth/oauth2` and its body is not + // readable in the published dist. So the reason to keep our own check is not a claim about upstream: + // it is that "every `aud` resolves to a registered resource" and "this token is for ME" are different + // questions, and only the second is the one a resource server must answer. Ours answers it. + payload = await oauthResourceClient.getActions().verifyBearerToken(token, { verifyOptions: { audience: getMcpResourceUrl(), issuer: getAuthIssuerUrl(), + // RFC 9068 §4: an access token must be typed `at+jwt`, and a resource server should refuse + // one that is not. Enforceable only from 1.7 — 1.6 issued no `typ` header at all, so + // requiring it before the upgrade would have rejected every token in circulation. + // + // Kept strict through the rolling deploy, deliberately. A 1.6-minted token (no `typ`) hitting a + // 1.7 pod is rejected here — but this check is on the RESOURCE SERVER only, not on the refresh + // path, and `20260812110001_eng_2343_backfill_oauth_resource_links` backfills + // `oauthRefreshToken.resources` precisely so existing refresh tokens keep working. So a client + // takes one 401, refreshes against the 1.7 authorization server, and retries with a typed token: + // self-healing in a single round trip, which is the 401 handling every MCP client already + // implements. Relaxing this to "absent is fine" would weaken a cross-JWT-confusion defence + // permanently to smooth a window that closes on its own — the wrong trade in the PR whose whole + // purpose is binding token audiences. + typ: JWT_ACCESS_TOKEN_TYPE, }, jwksUrl: `${getAuthIssuerUrl()}/jwks`, }); diff --git a/apps/web/modules/survey/list/components/survey-card.tsx b/apps/web/modules/survey/list/components/survey-card.tsx index f094021cb9ad..cc71a7836bfc 100644 --- a/apps/web/modules/survey/list/components/survey-card.tsx +++ b/apps/web/modules/survey/list/components/survey-card.tsx @@ -107,7 +107,7 @@ export const SurveyCard = ({ )}
- {survey.responseCount} + {survey.completedResponseCount}
diff --git a/apps/web/modules/survey/list/components/survey-list.tsx b/apps/web/modules/survey/list/components/survey-list.tsx index b562520d6aa5..8067e34bb4e2 100644 --- a/apps/web/modules/survey/list/components/survey-list.tsx +++ b/apps/web/modules/survey/list/components/survey-list.tsx @@ -327,10 +327,12 @@ export const SurveysList = ({ surveyContent = (
-
+
{t("common.name")}
{t("common.status")}
-
{t("common.responses")}
+
{t("workspace.surveys.completed_responses")}
{t("common.type")}
{t("common.created_at")}
{t("common.updated_at")}
diff --git a/apps/web/modules/survey/list/hooks/use-archive-survey.test.ts b/apps/web/modules/survey/list/hooks/use-archive-survey.test.ts index 60529cae108e..e0c9c47406e2 100644 --- a/apps/web/modules/survey/list/hooks/use-archive-survey.test.ts +++ b/apps/web/modules/survey/list/hooks/use-archive-survey.test.ts @@ -34,6 +34,7 @@ function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | nu createdAt: new Date("2026-04-15T10:00:00.000Z"), updatedAt: new Date("2026-04-15T10:00:00.000Z"), responseCount: 0, + completedResponseCount: 0, creator: { name: "Alice" }, singleUse: null, }, diff --git a/apps/web/modules/survey/list/hooks/use-delete-survey.test.ts b/apps/web/modules/survey/list/hooks/use-delete-survey.test.ts index cff89a4931f4..1469785a5ec4 100644 --- a/apps/web/modules/survey/list/hooks/use-delete-survey.test.ts +++ b/apps/web/modules/survey/list/hooks/use-delete-survey.test.ts @@ -34,6 +34,7 @@ function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | nu createdAt: new Date("2026-04-15T10:00:00.000Z"), updatedAt: new Date("2026-04-15T10:00:00.000Z"), responseCount: 0, + completedResponseCount: 0, creator: { name: "Alice" }, singleUse: null, }, diff --git a/apps/web/modules/survey/list/hooks/use-restore-survey.test.ts b/apps/web/modules/survey/list/hooks/use-restore-survey.test.ts index b20244a5d2b8..e7deb5d4cb46 100644 --- a/apps/web/modules/survey/list/hooks/use-restore-survey.test.ts +++ b/apps/web/modules/survey/list/hooks/use-restore-survey.test.ts @@ -35,6 +35,7 @@ function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | nu createdAt: new Date("2026-04-15T10:00:00.000Z"), updatedAt: new Date("2026-04-15T10:00:00.000Z"), responseCount: 0, + completedResponseCount: 0, creator: { name: "Alice" }, singleUse: null, }, diff --git a/apps/web/modules/survey/list/hooks/use-surveys.test.ts b/apps/web/modules/survey/list/hooks/use-surveys.test.ts index ee2e552f541d..81d9a9dd8550 100644 --- a/apps/web/modules/survey/list/hooks/use-surveys.test.ts +++ b/apps/web/modules/survey/list/hooks/use-surveys.test.ts @@ -44,6 +44,7 @@ describe("useSurveys", () => { createdAt: "2026-04-15T10:00:00.000Z", updatedAt: "2026-04-15T10:00:00.000Z", responseCount: 0, + completedResponseCount: 0, creator: { name: "Alice" }, singleUse: null, }, @@ -70,6 +71,7 @@ describe("useSurveys", () => { createdAt: "2026-04-15T11:00:00.000Z", updatedAt: "2026-04-15T11:00:00.000Z", responseCount: 2, + completedResponseCount: 2, creator: { name: "Bob" }, singleUse: null, }, @@ -149,6 +151,7 @@ describe("useSurveys", () => { createdAt: "2026-04-15T10:00:00.000Z", updatedAt: "2026-04-15T10:00:00.000Z", responseCount: 0, + completedResponseCount: 0, creator: { name: "Alice" }, singleUse: null, }, @@ -219,6 +222,7 @@ describe("useSurveys", () => { createdAt: "2026-04-15T11:00:00.000Z", updatedAt: "2026-04-15T11:00:00.000Z", responseCount: 4, + completedResponseCount: 4, creator: { name: "Bob" }, singleUse: null, }, diff --git a/apps/web/modules/survey/list/hooks/use-update-survey-status.test.ts b/apps/web/modules/survey/list/hooks/use-update-survey-status.test.ts index cb0cf524e06f..e07fe74d565a 100644 --- a/apps/web/modules/survey/list/hooks/use-update-survey-status.test.ts +++ b/apps/web/modules/survey/list/hooks/use-update-survey-status.test.ts @@ -34,6 +34,7 @@ function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | nu publishOn: null, archivedAt: null, responseCount: 5, + completedResponseCount: 5, creator: { name: "Alice" }, singleUse: null, }, @@ -48,6 +49,7 @@ function createQueryData(): { pages: TSurveyListPage[]; pageParams: (string | nu publishOn: null, archivedAt: null, responseCount: 0, + completedResponseCount: 0, creator: { name: "Bob" }, singleUse: null, }, diff --git a/apps/web/modules/survey/list/lib/query.test.ts b/apps/web/modules/survey/list/lib/query.test.ts index 08180234f2ab..6ab06b7b09b9 100644 --- a/apps/web/modules/survey/list/lib/query.test.ts +++ b/apps/web/modules/survey/list/lib/query.test.ts @@ -14,6 +14,7 @@ const surveyA = { createdAt: new Date("2026-04-15T10:00:00.000Z"), updatedAt: new Date("2026-04-15T10:00:00.000Z"), responseCount: 0, + completedResponseCount: 0, creator: { name: "Alice" }, singleUse: null, }; diff --git a/apps/web/modules/survey/list/lib/survey-page.test.ts b/apps/web/modules/survey/list/lib/survey-page.test.ts index bc0df2914cb4..e7bb3b200342 100644 --- a/apps/web/modules/survey/list/lib/survey-page.test.ts +++ b/apps/web/modules/survey/list/lib/survey-page.test.ts @@ -88,7 +88,8 @@ describe("getSurveyListPage", () => { makeSurveyRow({ id: "survey_1", updatedAt: new Date("2025-01-02T00:00:00.000Z") }), ] as never); vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "survey_2", _count: { _all: 3 } }, + { surveyId: "survey_2", finished: true, _count: { _all: 2 } }, + { surveyId: "survey_2", finished: false, _count: { _all: 1 } }, ] as never); const page = await getSurveyListPage(workspaceId, { @@ -106,7 +107,7 @@ describe("getSurveyListPage", () => { }); expect(page.surveys).toHaveLength(1); expect(page.surveys[0].responseCount).toBe(3); - expect(page.surveys[0]).not.toHaveProperty("_count"); + expect(page.surveys[0].completedResponseCount).toBe(2); expect(page.nextCursor).not.toBeNull(); expect(decodeSurveyListPageCursor(page.nextCursor as string, "updatedAt")).toEqual({ version: 1, @@ -131,7 +132,7 @@ describe("getSurveyListPage", () => { makeSurveyRow({ id: "survey_c", name: "Charlie" }), ] as never); vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "survey_c", _count: { _all: 3 } }, + { surveyId: "survey_c", finished: true, _count: { _all: 3 } }, ] as never); await getSurveyListPage(workspaceId, { @@ -174,8 +175,8 @@ describe("getSurveyListPage", () => { }), ] as never); vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "survey_in_progress", _count: { _all: 3 } }, - { surveyId: "survey_other_1", _count: { _all: 2 } }, + { surveyId: "survey_in_progress", finished: true, _count: { _all: 3 } }, + { surveyId: "survey_other_1", finished: true, _count: { _all: 2 } }, ] as never); const page = await getSurveyListPage(workspaceId, { @@ -231,7 +232,7 @@ describe("getSurveyListPage", () => { }), ] as never); vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "survey_in_progress", _count: { _all: 3 } }, + { surveyId: "survey_in_progress", finished: true, _count: { _all: 3 } }, ] as never); const page = await getSurveyListPage(workspaceId, { @@ -270,7 +271,7 @@ describe("getSurveyListPage", () => { }), ] as never); vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "survey_other_2", _count: { _all: 3 } }, + { surveyId: "survey_other_2", finished: true, _count: { _all: 3 } }, ] as never); const page = await getSurveyListPage(workspaceId, { diff --git a/apps/web/modules/survey/list/lib/survey-record.test.ts b/apps/web/modules/survey/list/lib/survey-record.test.ts new file mode 100644 index 000000000000..c7cd8dc92cc6 --- /dev/null +++ b/apps/web/modules/survey/list/lib/survey-record.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { + type TSurveyRow, + getResponseCountsBySurveyIds, + mapSurveyRowToSurvey, + mapSurveyRowsToSurveys, +} from "./survey-record"; + +vi.mock("server-only", () => ({})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + response: { + groupBy: vi.fn(), + }, + }, +})); + +const makeSurveyRow = (overrides: Partial = {}): TSurveyRow => + ({ + id: "survey_1", + name: "Survey 1", + workspaceId: "ws_1", + type: "link", + status: "inProgress", + publishOn: null, + archivedAt: null, + createdAt: new Date("2026-04-15T10:00:00.000Z"), + updatedAt: new Date("2026-04-16T10:00:00.000Z"), + creator: { name: "Alice" }, + singleUse: null, + ...overrides, + }) as TSurveyRow; + +describe("getResponseCountsBySurveyIds", () => { + beforeEach(() => { + vi.mocked(prisma.response.groupBy).mockReset(); + }); + + test("returns an empty map without querying when there are no survey ids", async () => { + const counts = await getResponseCountsBySurveyIds([]); + + expect(counts.size).toBe(0); + expect(prisma.response.groupBy).not.toHaveBeenCalled(); + }); + + test("groups by surveyId and finished so partial responses are excluded from the completed count", async () => { + vi.mocked(prisma.response.groupBy).mockResolvedValue([ + { surveyId: "survey_1", finished: true, _count: { _all: 4 } }, + { surveyId: "survey_1", finished: false, _count: { _all: 6 } }, + { surveyId: "survey_2", finished: false, _count: { _all: 3 } }, + ] as never); + + const counts = await getResponseCountsBySurveyIds(["survey_1", "survey_2"]); + + expect(prisma.response.groupBy).toHaveBeenCalledWith({ + by: ["surveyId", "finished"], + where: { surveyId: { in: ["survey_1", "survey_2"] } }, + _count: { _all: true }, + }); + expect(counts.get("survey_1")).toEqual({ total: 10, completed: 4 }); + expect(counts.get("survey_2")).toEqual({ total: 3, completed: 0 }); + }); + + test("omits surveys without any response", async () => { + vi.mocked(prisma.response.groupBy).mockResolvedValue([] as never); + + const counts = await getResponseCountsBySurveyIds(["survey_1"]); + + expect(counts.get("survey_1")).toBeUndefined(); + }); +}); + +describe("mapSurveyRowToSurvey", () => { + test("maps both counts onto the row", () => { + const survey = mapSurveyRowToSurvey(makeSurveyRow(), { total: 9, completed: 5 }); + + expect(survey.responseCount).toBe(9); + expect(survey.completedResponseCount).toBe(5); + }); + + test("defaults both counts to zero", () => { + const survey = mapSurveyRowToSurvey(makeSurveyRow()); + + expect(survey.responseCount).toBe(0); + expect(survey.completedResponseCount).toBe(0); + }); +}); + +describe("mapSurveyRowsToSurveys", () => { + test("matches each row with its own counts and falls back to zero", () => { + const rows = [makeSurveyRow({ id: "survey_1" }), makeSurveyRow({ id: "survey_2" })]; + const countsBySurveyId = new Map([["survey_1", { total: 8, completed: 3 }]]); + + const surveys = mapSurveyRowsToSurveys(rows, countsBySurveyId); + + expect(surveys[0]).toMatchObject({ id: "survey_1", responseCount: 8, completedResponseCount: 3 }); + expect(surveys[1]).toMatchObject({ id: "survey_2", responseCount: 0, completedResponseCount: 0 }); + }); + + test("defaults to zero counts when no map is given", () => { + const surveys = mapSurveyRowsToSurveys([makeSurveyRow()]); + + expect(surveys[0].responseCount).toBe(0); + expect(surveys[0].completedResponseCount).toBe(0); + }); +}); diff --git a/apps/web/modules/survey/list/lib/survey-record.ts b/apps/web/modules/survey/list/lib/survey-record.ts index 7d88ffc82bd6..4848949d75ee 100644 --- a/apps/web/modules/survey/list/lib/survey-record.ts +++ b/apps/web/modules/survey/list/lib/survey-record.ts @@ -18,20 +18,28 @@ export const surveySelect = { archivedAt: true, singleUse: true, workspaceId: true, - _count: { - select: { responses: true }, - }, } satisfies Prisma.SurveySelect; export type TSurveyRow = Prisma.SurveyGetPayload<{ select: typeof surveySelect }>; -export async function getResponseCountsBySurveyIds(surveyIds: string[]): Promise> { +export interface TSurveyResponseCounts { + /** Every response, including partial ones. */ + total: number; + /** Responses the respondent actually finished. */ + completed: number; +} + +export async function getResponseCountsBySurveyIds( + surveyIds: string[] +): Promise> { if (surveyIds.length === 0) { return new Map(); } + // Grouping by `finished` keeps both counts in a single query: the list shows the completed + // count, while the total still gates the "this survey already has responses" edit warning. const responseCounts = await prisma.response.groupBy({ - by: ["surveyId"], + by: ["surveyId", "finished"], where: { surveyId: { in: surveyIds, @@ -42,20 +50,36 @@ export async function getResponseCountsBySurveyIds(surveyIds: string[]): Promise }, }); - return new Map(responseCounts.map(({ surveyId, _count }) => [surveyId, _count._all])); + const countsBySurveyId = new Map(); + for (const { surveyId, finished, _count } of responseCounts) { + const counts = countsBySurveyId.get(surveyId) ?? { total: 0, completed: 0 }; + counts.total += _count._all; + if (finished) { + counts.completed += _count._all; + } + countsBySurveyId.set(surveyId, counts); + } + + return countsBySurveyId; } -export function mapSurveyRowToSurvey(row: TSurveyRow, responseCount = 0): TSurvey { - const { _count: _ignored, ...rest } = row; +/** Shared so the default doesn't allocate a throwaway object per mapped row (Sonar S7737). */ +const NO_RESPONSES: Readonly = Object.freeze({ total: 0, completed: 0 }); + +export function mapSurveyRowToSurvey( + row: TSurveyRow, + responseCounts: TSurveyResponseCounts = NO_RESPONSES +): TSurvey { return { - ...rest, - responseCount, + ...row, + responseCount: responseCounts.total, + completedResponseCount: responseCounts.completed, }; } export function mapSurveyRowsToSurveys( rows: TSurveyRow[], - responseCountsBySurveyId: Map = new Map() + responseCountsBySurveyId: Map = new Map() ): TSurvey[] { - return rows.map((row) => mapSurveyRowToSurvey(row, responseCountsBySurveyId.get(row.id) ?? 0)); + return rows.map((row) => mapSurveyRowToSurvey(row, responseCountsBySurveyId.get(row.id))); } diff --git a/apps/web/modules/survey/list/lib/survey.test.ts b/apps/web/modules/survey/list/lib/survey.test.ts index 1fcf755b7db4..a2afb3677e77 100644 --- a/apps/web/modules/survey/list/lib/survey.test.ts +++ b/apps/web/modules/survey/list/lib/survey.test.ts @@ -11,19 +11,11 @@ import { checkForInvalidMediaInBlocks } from "@/lib/survey/utils"; import { validateInputs } from "@/lib/utils/validate"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getQuotas } from "@/modules/ee/quotas/lib/quotas"; -import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils"; +import { buildWhereClause } from "@/modules/survey/lib/utils"; import { doesWorkspaceExist, getWorkspaceWithLanguages } from "@/modules/survey/list/lib/workspace"; -import { TSurvey, TWorkspaceWithLanguages } from "../types/surveys"; +import { TWorkspaceWithLanguages } from "../types/surveys"; // Import the module to be tested -import { - copySurveyToOtherWorkspace, - getSurvey, - getSurveyCount, - getSurveys, - getSurveysSortedByRelevance, - hasArchivedSurveys, -} from "./survey"; -import { surveySelect } from "./survey-record"; +import { copySurveyToOtherWorkspace, getSurveyCount, hasArchivedSurveys } from "./survey"; vi.mock("server-only", () => ({})); @@ -48,7 +40,6 @@ vi.mock("@/lib/organization/service", () => ({ })); vi.mock("@/modules/survey/lib/utils", () => ({ - buildOrderByClause: vi.fn((sortBy) => (sortBy ? [{ [sortBy]: "desc" }] : [])), buildWhereClause: vi.fn((filterCriteria) => (filterCriteria ? { name: filterCriteria.name } : {})), })); @@ -91,9 +82,6 @@ vi.mock("@formbricks/database", () => ({ delete: vi.fn(), findFirst: vi.fn(), }, - response: { - groupBy: vi.fn(), - }, language: { // Added for language connectOrCreate in copySurvey findUnique: vi.fn(), @@ -122,7 +110,6 @@ const resetMocks = () => { vi.mocked(reactCache).mockClear(); vi.mocked(checkForInvalidMediaInBlocks).mockClear(); vi.mocked(validateInputs).mockClear(); - vi.mocked(buildOrderByClause).mockClear(); vi.mocked(buildWhereClause).mockClear(); vi.mocked(doesWorkspaceExist).mockClear(); vi.mocked(getWorkspaceWithLanguages).mockClear(); @@ -136,7 +123,6 @@ const resetMocks = () => { vi.mocked(prisma.survey.create).mockReset(); vi.mocked(prisma.segment.delete).mockReset(); vi.mocked(prisma.segment.findFirst).mockReset(); - vi.mocked(prisma.response.groupBy).mockReset(); vi.mocked(prisma.actionClass.findMany).mockReset(); vi.mocked(getQuotas).mockReset(); vi.mocked(logger.error).mockClear(); @@ -154,21 +140,6 @@ const workspaceId = "ws_1"; const surveyId = "survey_1"; const userId = "user_1"; -const mockSurveyPrisma = { - id: surveyId, - createdAt: new Date(), - updatedAt: new Date(), - name: "Test Survey", - type: "web" as any, - creator: { name: "Test User" }, - status: "draft" as any, - publishOn: null, - archivedAt: null, - singleUse: null, - workspaceId, - _count: { responses: 10 }, -}; - describe("getSurveyCount", () => { beforeEach(() => { resetMocks(); @@ -198,272 +169,6 @@ describe("getSurveyCount", () => { }); }); -describe("getSurvey", () => { - beforeEach(() => { - resetMocks(); - }); - - test("should return a survey if found", async () => { - const prismaSurvey = { ...mockSurveyPrisma }; - vi.mocked(prisma.survey.findUnique).mockResolvedValue(prismaSurvey as any); - vi.mocked(prisma.response.groupBy).mockResolvedValue([{ surveyId, _count: { _all: 5 } }] as any); - - const survey = await getSurvey(surveyId); - - expect(survey).toEqual({ - id: prismaSurvey.id, - createdAt: prismaSurvey.createdAt, - updatedAt: prismaSurvey.updatedAt, - name: prismaSurvey.name, - type: prismaSurvey.type, - creator: prismaSurvey.creator, - status: prismaSurvey.status, - publishOn: prismaSurvey.publishOn, - archivedAt: prismaSurvey.archivedAt, - singleUse: prismaSurvey.singleUse, - workspaceId: prismaSurvey.workspaceId, - responseCount: 5, - }); - expect(survey).not.toHaveProperty("_count"); - expect(prisma.survey.findUnique).toHaveBeenCalledWith({ - where: { id: surveyId }, - select: surveySelect, - }); - }); - - test("should return null if survey not found", async () => { - vi.mocked(prisma.survey.findUnique).mockResolvedValue(null); - const survey = await getSurvey(surveyId); - expect(survey).toBeNull(); - }); - - test("should throw DatabaseError on Prisma error", async () => { - const prismaError = makePrismaKnownError(); - vi.mocked(prisma.survey.findUnique).mockRejectedValue(prismaError); - await expect(getSurvey(surveyId)).rejects.toThrow(DatabaseError); - expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting survey"); - }); - - test("should throw DatabaseError when response count lookup fails", async () => { - const prismaError = makePrismaKnownError(); - vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurveyPrisma } as any); - vi.mocked(prisma.response.groupBy).mockRejectedValue(prismaError); - - await expect(getSurvey(surveyId)).rejects.toThrow(DatabaseError); - expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting survey"); - }); - - test("should rethrow unknown error", async () => { - const unknownError = new Error("Unknown error"); - vi.mocked(prisma.survey.findUnique).mockRejectedValue(unknownError); - await expect(getSurvey(surveyId)).rejects.toThrow(unknownError); - }); -}); - -describe("getSurveys", () => { - beforeEach(() => { - resetMocks(); - }); - - const mockPrismaSurveys = [ - { ...mockSurveyPrisma, id: "s1", name: "Survey 1" }, - { ...mockSurveyPrisma, id: "s2", name: "Survey 2" }, - ]; - const expectedSurveys: TSurvey[] = mockPrismaSurveys.map((s) => ({ - id: s.id, - createdAt: s.createdAt, - updatedAt: s.updatedAt, - name: s.name, - type: s.type, - creator: s.creator, - status: s.status, - publishOn: s.publishOn, - archivedAt: s.archivedAt, - singleUse: s.singleUse, - workspaceId: s.workspaceId, - responseCount: s._count.responses, - })); - - test("should return surveys with default parameters", async () => { - vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys as any); - vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "s1", _count: { _all: 10 } }, - { surveyId: "s2", _count: { _all: 10 } }, - ] as any); - const surveys = await getSurveys(workspaceId); - - expect(surveys).toEqual(expectedSurveys); - expect(surveys[0]).not.toHaveProperty("_count"); - expect(prisma.survey.findMany).toHaveBeenCalledWith({ - where: { workspaceId, ...buildWhereClause() }, - select: surveySelect, - orderBy: buildOrderByClause(), - take: undefined, - skip: undefined, - }); - }); - - test("should return surveys with limit and offset", async () => { - vi.mocked(prisma.survey.findMany).mockResolvedValue([mockPrismaSurveys[0]] as any); - vi.mocked(prisma.response.groupBy).mockResolvedValue([{ surveyId: "s1", _count: { _all: 10 } }] as any); - const surveys = await getSurveys(workspaceId, 1, 1); - - expect(surveys).toEqual([expectedSurveys[0]]); - expect(prisma.survey.findMany).toHaveBeenCalledWith({ - where: { workspaceId, ...buildWhereClause() }, - select: surveySelect, - orderBy: buildOrderByClause(), - take: 1, - skip: 1, - }); - }); - - test("should return surveys with filterCriteria", async () => { - const filterCriteria: any = { name: "Test", sortBy: "createdAt" }; - vi.mocked(buildWhereClause).mockReturnValue({ AND: [{ name: { contains: "Test" } }] }); // Mock correct return type - vi.mocked(buildOrderByClause).mockReturnValue([{ createdAt: "desc" }]); // Mock specific return - vi.mocked(prisma.survey.findMany).mockResolvedValue(mockPrismaSurveys as any); - vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "s1", _count: { _all: 10 } }, - { surveyId: "s2", _count: { _all: 10 } }, - ] as any); - - const surveys = await getSurveys(workspaceId, undefined, undefined, filterCriteria); - - expect(surveys).toEqual(expectedSurveys); - expect(buildWhereClause).toHaveBeenCalledWith(filterCriteria); - expect(buildOrderByClause).toHaveBeenCalledWith("createdAt"); - expect(prisma.survey.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { workspaceId, AND: [{ name: { contains: "Test" } }] }, // Check with correct structure - orderBy: [{ createdAt: "desc" }], // Check the mocked order by - }) - ); - }); - - test("should throw DatabaseError on Prisma error", async () => { - const prismaError = makePrismaKnownError(); - vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError); - await expect(getSurveys(workspaceId)).rejects.toThrow(DatabaseError); - expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys"); - }); - - test("should rethrow unknown error", async () => { - const unknownError = new Error("Unknown error"); - vi.mocked(prisma.survey.findMany).mockRejectedValue(unknownError); - await expect(getSurveys(workspaceId)).rejects.toThrow(unknownError); - }); -}); - -describe("getSurveysSortedByRelevance", () => { - beforeEach(() => { - resetMocks(); - }); - - const mockInProgressPrisma = { - ...mockSurveyPrisma, - id: "s_inprog", - status: "inProgress" as any, - }; - const mockOtherPrisma = { - ...mockSurveyPrisma, - id: "s_other", - status: "completed" as any, - }; - - const expectedInProgressSurvey: TSurvey = { - id: mockInProgressPrisma.id, - createdAt: mockInProgressPrisma.createdAt, - updatedAt: mockInProgressPrisma.updatedAt, - name: mockInProgressPrisma.name, - type: mockInProgressPrisma.type, - creator: mockInProgressPrisma.creator, - status: mockInProgressPrisma.status, - publishOn: mockInProgressPrisma.publishOn, - archivedAt: mockInProgressPrisma.archivedAt, - singleUse: mockInProgressPrisma.singleUse, - workspaceId: mockInProgressPrisma.workspaceId, - responseCount: 3, - }; - const expectedOtherSurvey: TSurvey = { - id: mockOtherPrisma.id, - createdAt: mockOtherPrisma.createdAt, - updatedAt: mockOtherPrisma.updatedAt, - name: mockOtherPrisma.name, - type: mockOtherPrisma.type, - creator: mockOtherPrisma.creator, - status: mockOtherPrisma.status, - publishOn: mockOtherPrisma.publishOn, - archivedAt: mockOtherPrisma.archivedAt, - singleUse: mockOtherPrisma.singleUse, - workspaceId: mockOtherPrisma.workspaceId, - responseCount: 5, - }; - - test("should fetch inProgress surveys first, then others if limit not met", async () => { - vi.mocked(prisma.survey.count).mockResolvedValue(1); // 1 inProgress survey - vi.mocked(prisma.survey.findMany) - .mockResolvedValueOnce([mockInProgressPrisma] as any) // In-progress surveys - .mockResolvedValueOnce([mockOtherPrisma] as any); // Additional surveys - vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "s_inprog", _count: { _all: 3 } }, - { surveyId: "s_other", _count: { _all: 5 } }, - ] as any); - - const surveys = await getSurveysSortedByRelevance(workspaceId, 2, 0); - - expect(surveys).toEqual([expectedInProgressSurvey, expectedOtherSurvey]); - expect(surveys[0]).not.toHaveProperty("_count"); - expect(prisma.survey.count).toHaveBeenCalledWith({ - where: { workspaceId, status: "inProgress", ...buildWhereClause() }, - }); - expect(prisma.survey.findMany).toHaveBeenNthCalledWith(1, { - where: { workspaceId, status: "inProgress", ...buildWhereClause() }, - select: surveySelect, - orderBy: buildOrderByClause("updatedAt"), - take: 2, - skip: 0, - }); - expect(prisma.survey.findMany).toHaveBeenNthCalledWith(2, { - where: { workspaceId, status: { not: "inProgress" }, ...buildWhereClause() }, - select: surveySelect, - orderBy: buildOrderByClause("updatedAt"), - take: 1, - skip: 0, - }); - }); - - test("should only fetch inProgress surveys if limit is met", async () => { - vi.mocked(prisma.survey.count).mockResolvedValue(1); - vi.mocked(prisma.survey.findMany).mockResolvedValueOnce([mockInProgressPrisma] as any); - vi.mocked(prisma.response.groupBy).mockResolvedValue([ - { surveyId: "s_inprog", _count: { _all: 3 } }, - ] as any); - - const surveys = await getSurveysSortedByRelevance(workspaceId, 1, 0); - expect(surveys).toEqual([expectedInProgressSurvey]); - expect(prisma.survey.findMany).toHaveBeenCalledTimes(1); - }); - - test("should throw DatabaseError on Prisma error", async () => { - const prismaError = makePrismaKnownError(); - vi.mocked(prisma.survey.count).mockRejectedValue(prismaError); - await expect(getSurveysSortedByRelevance(workspaceId)).rejects.toThrow(DatabaseError); - expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys sorted by relevance"); - - resetMocks(); // Reset for the next part of the test - vi.mocked(prisma.survey.count).mockResolvedValue(0); // Make count succeed - vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError); // Error on findMany - await expect(getSurveysSortedByRelevance(workspaceId)).rejects.toThrow(DatabaseError); - }); - - test("should rethrow unknown error", async () => { - const unknownError = new Error("Unknown error"); - vi.mocked(prisma.survey.count).mockRejectedValue(unknownError); - await expect(getSurveysSortedByRelevance(workspaceId)).rejects.toThrow(unknownError); - }); -}); - const mockExistingSurveyDetails = { name: "Original Survey", type: "web" as any, diff --git a/apps/web/modules/survey/list/lib/survey.ts b/apps/web/modules/survey/list/lib/survey.ts index 3b3476ff74d0..7de331d2561d 100644 --- a/apps/web/modules/survey/list/lib/survey.ts +++ b/apps/web/modules/survey/list/lib/survey.ts @@ -14,151 +14,9 @@ import { validateInputs } from "@/lib/utils/validate"; import { getTranslate } from "@/lingodotdev/server"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getQuotas } from "@/modules/ee/quotas/lib/quotas"; -import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils"; +import { buildWhereClause } from "@/modules/survey/lib/utils"; import { doesWorkspaceExist, getWorkspaceWithLanguages } from "@/modules/survey/list/lib/workspace"; -import type { TSurvey, TWorkspaceWithLanguages } from "@/modules/survey/list/types/surveys"; -import { - type TSurveyRow, - getResponseCountsBySurveyIds, - mapSurveyRowToSurvey, - mapSurveyRowsToSurveys, - surveySelect, -} from "./survey-record"; - -export const getSurveys = reactCache( - async ( - workspaceId: string, - limit?: number, - offset?: number, - filterCriteria?: TSurveyFilterCriteria - ): Promise => { - try { - if (filterCriteria?.sortBy === "relevance") { - // Call the sortByRelevance function - return await getSurveysSortedByRelevance(workspaceId, limit, offset ?? 0, filterCriteria); - } - - // Fetch surveys normally with pagination and include response count - const surveysPrisma = await prisma.survey.findMany({ - where: { - workspaceId, - ...buildWhereClause(filterCriteria), - }, - select: surveySelect, - orderBy: buildOrderByClause(filterCriteria?.sortBy), - take: limit, - skip: offset, - }); - - const responseCountsBySurveyId = await getResponseCountsBySurveyIds( - surveysPrisma.map((survey) => survey.id) - ); - - return mapSurveyRowsToSurveys(surveysPrisma, responseCountsBySurveyId); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - logger.error(error, "Error getting surveys"); - throw new DatabaseError(error.message); - } - throw error; - } - } -); - -export const getSurveysSortedByRelevance = reactCache( - async ( - workspaceId: string, - limit?: number, - offset?: number, - filterCriteria?: TSurveyFilterCriteria - ): Promise => { - try { - let surveyRows: TSurveyRow[] = []; - - const inProgressSurveyCount = await prisma.survey.count({ - where: { - workspaceId, - status: "inProgress", - ...buildWhereClause(filterCriteria), - }, - }); - - // Fetch surveys that are in progress first - const inProgressSurveys = - offset && offset > inProgressSurveyCount - ? [] - : await prisma.survey.findMany({ - where: { - workspaceId, - status: "inProgress", - ...buildWhereClause(filterCriteria), - }, - select: surveySelect, - orderBy: buildOrderByClause("updatedAt"), - take: limit, - skip: offset, - }); - - surveyRows = inProgressSurveys; - - // Determine if additional surveys are needed - if (offset !== undefined && limit && inProgressSurveys.length < limit) { - const remainingLimit = limit - inProgressSurveys.length; - const newOffset = Math.max(0, offset - inProgressSurveyCount); - const additionalSurveys = await prisma.survey.findMany({ - where: { - workspaceId, - status: { not: "inProgress" }, - ...buildWhereClause(filterCriteria), - }, - select: surveySelect, - orderBy: buildOrderByClause("updatedAt"), - take: remainingLimit, - skip: newOffset, - }); - - surveyRows = [...surveyRows, ...additionalSurveys]; - } - - const responseCountsBySurveyId = await getResponseCountsBySurveyIds( - surveyRows.map((survey) => survey.id) - ); - - return mapSurveyRowsToSurveys(surveyRows, responseCountsBySurveyId); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - logger.error(error, "Error getting surveys sorted by relevance"); - throw new DatabaseError(error.message); - } - throw error; - } - } -); - -export const getSurvey = reactCache(async (surveyId: string): Promise => { - try { - const surveyPrisma = await prisma.survey.findUnique({ - where: { - id: surveyId, - }, - select: surveySelect, - }); - - if (!surveyPrisma) { - return null; - } - - const responseCountsBySurveyId = await getResponseCountsBySurveyIds([surveyPrisma.id]); - - return mapSurveyRowToSurvey(surveyPrisma, responseCountsBySurveyId.get(surveyPrisma.id) ?? 0); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - logger.error(error, "Error getting survey"); - throw new DatabaseError(error.message); - } - throw error; - } -}); +import type { TWorkspaceWithLanguages } from "@/modules/survey/list/types/surveys"; const getExistingSurvey = async (surveyId: string) => { return await prisma.survey.findUnique({ diff --git a/apps/web/modules/survey/list/types/survey-overview.ts b/apps/web/modules/survey/list/types/survey-overview.ts index 9160e3fd4146..359433dd458e 100644 --- a/apps/web/modules/survey/list/types/survey-overview.ts +++ b/apps/web/modules/survey/list/types/survey-overview.ts @@ -24,6 +24,7 @@ export const ZSurveyListItem = z.object({ createdAt: z.date(), updatedAt: z.date(), responseCount: z.number(), + completedResponseCount: z.number(), creator: z .object({ name: z.string(), diff --git a/apps/web/modules/survey/list/types/surveys.ts b/apps/web/modules/survey/list/types/surveys.ts index 217fbde29ddf..068dd1b356b3 100644 --- a/apps/web/modules/survey/list/types/surveys.ts +++ b/apps/web/modules/survey/list/types/surveys.ts @@ -13,6 +13,7 @@ export const ZSurvey = z.object({ createdAt: z.date(), updatedAt: z.date(), responseCount: z.number(), + completedResponseCount: z.number(), creator: z .object({ name: z.string(), diff --git a/apps/web/modules/ui/components/options-switch/index.tsx b/apps/web/modules/ui/components/options-switch/index.tsx index cab44e95b937..cd22563bb67d 100644 --- a/apps/web/modules/ui/components/options-switch/index.tsx +++ b/apps/web/modules/ui/components/options-switch/index.tsx @@ -11,15 +11,22 @@ interface OptionsSwitchProps { options: TOption[]; currentOption: string | undefined; handleOptionChange: (value: string) => void; + /** + * Id of the element naming this group. A plain `