diff --git a/.github/actions/cache-build-web/action.yml b/.github/actions/cache-build-web/action.yml index edec0b94bdac..98fcb6684ff6 100644 --- a/.github/actions/cache-build-web/action.yml +++ b/.github/actions/cache-build-web/action.yml @@ -5,12 +5,6 @@ inputs: description: "Set E2E Testing Mode" required: false default: "0" - turbo_token: - description: "Turborepo token" - required: false - turbo_team: - description: "Turborepo team" - required: false runs: using: "composite" @@ -38,15 +32,18 @@ runs: run: echo "cache-hit=${{ steps.cache-build.outputs.cache-hit }}" >> "$GITHUB_OUTPUT" shell: bash + # pnpm + Node are set up unconditionally: even on a build-cache hit the steps + # below still run `pnpm dev:setup` (regenerates .env), which needs the pnpm + # binary on PATH. The expensive dependency install and build stay gated on a + # cache miss. + - name: Install pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - name: Setup Node.js (version from .nvmrc) uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ".nvmrc" - if: steps.cache-build.outputs.cache-hit != 'true' - - - name: Install pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - if: steps.cache-build.outputs.cache-hit != 'true' + cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 @@ -68,6 +65,3 @@ runs: pnpm build --filter=@formbricks/web... if: steps.cache-build.outputs.cache-hit != 'true' shell: bash - env: - TURBO_TOKEN: ${{ inputs.turbo_token }} - TURBO_TEAM: ${{ inputs.turbo_team }} diff --git a/.github/workflows/api-v3-spec.yml b/.github/workflows/api-v3-spec.yml index ec59780b2912..71193592a148 100644 --- a/.github/workflows/api-v3-spec.yml +++ b/.github/workflows/api-v3-spec.yml @@ -29,13 +29,14 @@ jobs: with: persist-credentials: false + - name: Install pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - name: Setup Node.js (version from .nvmrc) uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ".nvmrc" - - - name: Install pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + cache: pnpm # Root importer only: installs the lockfile-pinned @redocly/cli (and other root devDeps) # without building the whole workspace. bundle.mjs falls back to pinned npx when absent. diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 80ddf8b0dfcb..8b3522e1478e 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -25,5 +25,3 @@ jobs: id: cache-build-web with: e2e_testing_mode: "0" - turbo_token: ${{ secrets.TURBO_TOKEN }} - turbo_team: ${{ vars.TURBO_TEAM }} diff --git a/.github/workflows/docker-build-validation.yml b/.github/workflows/docker-build-validation.yml index 5717d6394f62..4b9fde5a0b44 100644 --- a/.github/workflows/docker-build-validation.yml +++ b/.github/workflows/docker-build-validation.yml @@ -12,10 +12,6 @@ on: permissions: contents: read -env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: ${{ vars.TURBO_TEAM }} - jobs: validate-docker-build: name: Validate Docker Build diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index a7452fe2c7b0..e95b418e8018 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -26,10 +26,6 @@ on: merge_group: workflow_dispatch: -env: - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_TEAM: ${{ vars.TURBO_TEAM }} - # Cancel superseded runs on the same PR/ref (matches pr.yml). concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -88,15 +84,16 @@ jobs: fi shell: bash + - name: Install pnpm + if: steps.harness.outputs.present == 'true' + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - name: Setup Node.js (version from .nvmrc) if: steps.harness.outputs.present == 'true' uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ".nvmrc" - - - name: Install pnpm - if: steps.harness.outputs.present == 'true' - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + cache: pnpm - name: Install dependencies if: steps.harness.outputs.present == 'true' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d4f1f965690f..60c71b1fb387 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,13 +20,14 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: ./.github/actions/dangerous-git-checkout + - name: Install pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - name: Setup Node.js (version from .nvmrc) uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ".nvmrc" - - - name: Install pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 40999be1b443..0ebb5a2564cb 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -24,10 +24,14 @@ jobs: with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Install pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - name: Setup Node.js (version from .nvmrc) uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ".nvmrc" + cache: pnpm - name: Setup Java 21 uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 @@ -35,9 +39,6 @@ jobs: distribution: temurin java-version: "21" - - name: Install pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - - name: Install dependencies run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fdebfff01ed9..c27ec72fdebb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,13 +21,14 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: ./.github/actions/dangerous-git-checkout + - name: Install pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - name: Setup Node.js (version from .nvmrc) uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ".nvmrc" - - - name: Install pnpm - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 diff --git a/.github/workflows/translation-check.yml b/.github/workflows/translation-check.yml index 7fa29d71d667..8baffb72caff 100644 --- a/.github/workflows/translation-check.yml +++ b/.github/workflows/translation-check.yml @@ -40,15 +40,16 @@ jobs: - 'packages/i18n-utils/**' - '**/i18n.lock' + - name: Install pnpm + if: steps.changes.outputs.translations == 'true' + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - name: Setup Node.js (version from .nvmrc) if: steps.changes.outputs.translations == 'true' uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: ".nvmrc" - - - name: Install pnpm - if: steps.changes.outputs.translations == 'true' - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + cache: pnpm - name: Install dependencies if: steps.changes.outputs.translations == 'true' diff --git a/apps/web/lib/image-hosts.test.ts b/apps/web/lib/image-hosts.test.ts index ef92a42c72cd..94328356cb58 100644 --- a/apps/web/lib/image-hosts.test.ts +++ b/apps/web/lib/image-hosts.test.ts @@ -40,4 +40,19 @@ describe("isExternalImageSrc", () => { test("does not include the deployment domain in the optimizable host allowlist", () => { expect(OPTIMIZABLE_IMAGE_HOSTS).not.toContain("app.formbricks.com"); }); + + test("treats an allowlisted public host over plain http as external (protocol mismatches remotePatterns' https)", () => { + expect(isExternalImageSrc("http://images.unsplash.com/photo-1.jpg")).toBe(true); + expect(isExternalImageSrc("http://avatars.githubusercontent.com/u/1")).toBe(true); + }); + + test("treats an allowlisted loopback host over https as external (protocol mismatches remotePatterns' http)", () => { + expect(isExternalImageSrc("https://localhost:3000/x.png")).toBe(true); + expect(isExternalImageSrc("https://127.0.0.1:3000/x.png")).toBe(true); + }); + + test("treats an allowlisted loopback host over http as optimizable", () => { + expect(isExternalImageSrc("http://localhost:3000/x.png")).toBe(false); + expect(isExternalImageSrc("http://127.0.0.1:3000/x.png")).toBe(false); + }); }); diff --git a/apps/web/lib/image-hosts.ts b/apps/web/lib/image-hosts.ts index dc256674ce92..7bce9c8e07b6 100644 --- a/apps/web/lib/image-hosts.ts +++ b/apps/web/lib/image-hosts.ts @@ -1,11 +1,19 @@ import { type StaticImageData } from "next/image"; -import { OPTIMIZABLE_IMAGE_HOSTS } from "./optimizable-image-hosts.mjs"; +import { LOOPBACK_HOSTS, OPTIMIZABLE_IMAGE_HOSTS } from "./optimizable-image-hosts.mjs"; // Re-exported from the plain `.mjs` source of truth (also imported by next.config.mjs) so // remotePatterns and the runtime check below share one list. See ./optimizable-image-hosts.mjs. export { OPTIMIZABLE_IMAGE_HOSTS }; const OPTIMIZABLE_IMAGE_HOSTS_SET: ReadonlySet = new Set(OPTIMIZABLE_IMAGE_HOSTS); +const LOOPBACK_HOSTS_SET: ReadonlySet = new Set(LOOPBACK_HOSTS); + +// Must mirror the protocol `next.config.mjs` pins per host in `images.remotePatterns`: `http` for +// loopback hosts, `https` for every other allowlisted (public) host. Next.js matches `remotePatterns` +// protocol strictly, so a src whose protocol disagrees would be rejected by the optimizer even though +// its hostname is allowlisted. +const optimizableProtocolFor = (hostname: string): string => + LOOPBACK_HOSTS_SET.has(hostname) ? "http:" : "https:"; /** * Whether an `` src must be rendered with `unoptimized` because the Next.js image optimizer @@ -14,11 +22,14 @@ const OPTIMIZABLE_IMAGE_HOSTS_SET: ReadonlySet = new Set(OPTIMIZABLE_IMA * Returns `false` (i.e. optimize) for: * - relative paths (`/storage/...`, `/images/...`) — local images, optimized via `localPatterns`; * - `data:` URIs, `StaticImageData` imports, or empty/nullish values; - * - absolute URLs whose host is in {@link OPTIMIZABLE_IMAGE_HOSTS}. + * - absolute URLs whose host is in {@link OPTIMIZABLE_IMAGE_HOSTS} AND whose protocol matches what + * `next.config.mjs` generates for that host in `remotePatterns` (`http` for loopback, `https` + * otherwise). * * Returns `true` (i.e. bypass the optimizer, serve directly) for any other absolute `http(s)` URL — - * i.e. arbitrary user-provided external images. This keeps the optimizer from acting as an open - * proxy for hosts we don't control, without breaking rendering of those images. + * i.e. arbitrary user-provided external images, or an allowlisted host requested over the "wrong" + * protocol (which the optimizer would reject with a 400 anyway). This keeps the optimizer from acting + * as an open proxy for hosts we don't control, without breaking rendering of those images. * * The decision depends only on the src string, so it is identical on the server and client (no * hydration mismatch). @@ -27,7 +38,10 @@ export const isExternalImageSrc = (src: string | StaticImageData | null | undefi if (!src || typeof src !== "string") return false; if (!/^https?:\/\//i.test(src)) return false; // relative path or data: URI → local/optimizable try { - return !OPTIMIZABLE_IMAGE_HOSTS_SET.has(new URL(src).hostname); + const url = new URL(src); + const isOptimizableHost = OPTIMIZABLE_IMAGE_HOSTS_SET.has(url.hostname); + const hasOptimizableProtocol = url.protocol === optimizableProtocolFor(url.hostname); + return !(isOptimizableHost && hasOptimizableProtocol); } catch { return false; } diff --git a/apps/web/lib/optimizable-image-hosts.mjs b/apps/web/lib/optimizable-image-hosts.mjs index 0649dd913dff..3ab4af44bafc 100644 --- a/apps/web/lib/optimizable-image-hosts.mjs +++ b/apps/web/lib/optimizable-image-hosts.mjs @@ -15,6 +15,12 @@ * It therefore contains only *universal* provider hosts that real features rely on and that are * identical on every deployment. */ +// Loopback hosts are only ever reachable over plain HTTP (local development); every other +// allowlisted host is a public provider only ever reachable over HTTPS. Exported so both +// `next.config.mjs` (remotePatterns protocol) and `lib/image-hosts.ts` (runtime protocol check) +// agree on which hosts are loopback instead of each keeping their own copy. +export const LOOPBACK_HOSTS = ["localhost", "127.0.0.1"]; + export const OPTIMIZABLE_IMAGE_HOSTS = [ // OAuth profile avatars "avatars.githubusercontent.com", @@ -23,6 +29,5 @@ export const OPTIMIZABLE_IMAGE_HOSTS = [ // survey editor's Unsplash background picker "images.unsplash.com", // local development - "localhost", - "127.0.0.1", + ...LOOPBACK_HOSTS, ]; diff --git a/apps/web/lib/turbo-build-env.test.ts b/apps/web/lib/turbo-build-env.test.ts index c890346add5f..91b2ec0b6cff 100644 --- a/apps/web/lib/turbo-build-env.test.ts +++ b/apps/web/lib/turbo-build-env.test.ts @@ -5,8 +5,8 @@ import { describe, expect, test } from "vitest"; // Guards the coupling between next.config.mjs and turbo.json (see ENG-1663): every env var read in // next.config.mjs shapes the build output, so it must be part of Turborepo's cache key. A var that -// is missing from `build.env` (or filed under `passThroughEnv`) makes Turborepo — including the CI -// remote cache — replay stale builds when the var's value changes. +// is missing from `build.env` (or filed under `passThroughEnv`) makes Turborepo — the local cache +// and the CI build-output cache alike — replay stale builds when the var's value changes. const here = path.dirname(fileURLToPath(import.meta.url)); const nextConfigPath = path.resolve(here, "..", "next.config.mjs"); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.test.ts new file mode 100644 index 000000000000..bbeae5da9325 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; + +const { + mockAuthenticatedApiClient, + mockCanManageOrganizationUsers, + mockDeleteTeam, + mockGetApiKeyCreatorRole, + mockGetTeam, + mockHandleApiError, + mockSuccessResponse, + mockUpdateTeam, +} = vi.hoisted(() => ({ + mockAuthenticatedApiClient: vi.fn(), + mockCanManageOrganizationUsers: vi.fn(), + mockDeleteTeam: vi.fn(), + mockGetApiKeyCreatorRole: vi.fn(), + mockGetTeam: vi.fn(), + mockHandleApiError: vi.fn(), + mockSuccessResponse: vi.fn(), + mockUpdateTeam: vi.fn(), +})); + +vi.mock("@/modules/api/v2/auth/authenticated-api-client", () => ({ + authenticatedApiClient: mockAuthenticatedApiClient, +})); + +vi.mock("@/modules/api/v2/lib/response", () => ({ + responses: { + successResponse: mockSuccessResponse, + }, +})); + +vi.mock("@/modules/api/v2/lib/utils", () => ({ + handleApiError: mockHandleApiError, +})); + +vi.mock("@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams", () => ({ + deleteTeam: mockDeleteTeam, + getTeam: mockGetTeam, + updateTeam: mockUpdateTeam, +})); + +vi.mock("@/modules/api/v2/organizations/[organizationId]/users/lib/utils", () => ({ + canManageOrganizationUsers: mockCanManageOrganizationUsers, + getApiKeyCreatorRole: mockGetApiKeyCreatorRole, +})); + +const organizationId = "org123"; +const apiKeyId = "apiKey123"; +const teamId = "team123"; +const team = { id: teamId, organizationId, name: "Test Team" }; + +const buildRequest = (method: string) => + new Request(`http://localhost/api/v2/organizations/org123/teams/${teamId}`, { method }); + +describe("PUT/DELETE /organizations/[organizationId]/teams/[teamId]", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockAuthenticatedApiClient.mockImplementation( + async ({ handler }: Parameters[0]) => + await handler({ + request: buildRequest("DELETE"), + auditLog: undefined, + authentication: { + type: "apiKey", + apiKeyId, + organizationId, + workspacePermissions: [], + organizationAccess: { accessControl: { read: true, write: true } }, + }, + parsedInput: { + body: { name: "Renamed Team" }, + params: { organizationId, teamId }, + }, + }) + ); + mockHandleApiError.mockImplementation((_request, error) => Response.json({ error }, { status: 403 })); + mockSuccessResponse.mockImplementation((body: unknown) => Response.json(body, { status: 200 })); + mockGetTeam.mockResolvedValue({ ok: true, data: team }); + }); + + describe("DELETE", () => { + test("denies team deletion when the API key creator can no longer manage users", async () => { + mockGetApiKeyCreatorRole.mockResolvedValue(null); + mockCanManageOrganizationUsers.mockReturnValue(false); + + const { DELETE } = await import("./route"); + const response = await DELETE(buildRequest("DELETE"), { + params: Promise.resolve({ organizationId, teamId }), + }); + + expect(mockGetApiKeyCreatorRole).toHaveBeenCalledWith(apiKeyId, organizationId); + expect(mockCanManageOrganizationUsers).toHaveBeenCalledWith(null); + expect(mockDeleteTeam).not.toHaveBeenCalled(); + expect(mockHandleApiError).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: "forbidden" }), + undefined + ); + expect(response.status).toBe(403); + }); + + test("deletes the team when the API key creator clears the user-management floor", async () => { + mockGetApiKeyCreatorRole.mockResolvedValue("manager"); + mockCanManageOrganizationUsers.mockReturnValue(true); + mockDeleteTeam.mockResolvedValue({ ok: true, data: team }); + + const { DELETE } = await import("./route"); + const response = await DELETE(buildRequest("DELETE"), { + params: Promise.resolve({ organizationId, teamId }), + }); + + expect(mockCanManageOrganizationUsers).toHaveBeenCalledWith("manager"); + expect(mockDeleteTeam).toHaveBeenCalledWith(organizationId, teamId); + expect(response.status).toBe(200); + }); + }); + + describe("PUT", () => { + test("denies team rename when the API key creator can no longer manage users", async () => { + mockGetApiKeyCreatorRole.mockResolvedValue(null); + mockCanManageOrganizationUsers.mockReturnValue(false); + + const { PUT } = await import("./route"); + const response = await PUT(buildRequest("PUT"), { + params: Promise.resolve({ organizationId, teamId }), + }); + + expect(mockGetApiKeyCreatorRole).toHaveBeenCalledWith(apiKeyId, organizationId); + expect(mockCanManageOrganizationUsers).toHaveBeenCalledWith(null); + expect(mockUpdateTeam).not.toHaveBeenCalled(); + expect(mockHandleApiError).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: "forbidden" }), + undefined + ); + expect(response.status).toBe(403); + }); + + test("renames the team when the API key creator clears the user-management floor", async () => { + mockGetApiKeyCreatorRole.mockResolvedValue("manager"); + mockCanManageOrganizationUsers.mockReturnValue(true); + mockUpdateTeam.mockResolvedValue({ ok: true, data: { ...team, name: "Renamed Team" } }); + + const { PUT } = await import("./route"); + const response = await PUT(buildRequest("PUT"), { + params: Promise.resolve({ organizationId, teamId }), + }); + + expect(mockCanManageOrganizationUsers).toHaveBeenCalledWith("manager"); + expect(mockUpdateTeam).toHaveBeenCalledWith(organizationId, teamId, { name: "Renamed Team" }); + expect(response.status).toBe(200); + }); + }); +}); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts index e8319c12a205..d1fdba9ac217 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts @@ -15,6 +15,10 @@ import { ZTeamUpdateSchema, } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams"; import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations"; +import { + canManageOrganizationUsers, + getApiKeyCreatorRole, +} from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; @@ -73,6 +77,18 @@ export const DELETE = async ( ); } + const assignerRole = await getApiKeyCreatorRole(authentication.apiKeyId, authentication.organizationId); + if (!canManageOrganizationUsers(assignerRole)) { + return handleApiError( + request, + { + type: "forbidden", + details: [{ field: "team", issue: "You are not allowed to manage teams in this organization" }], + }, + auditLog + ); + } + let oldTeamData: any = UNKNOWN_DATA; try { const oldTeamResult = await getTeam(params.organizationId, params.teamId); @@ -127,6 +143,18 @@ export const PUT = ( ); } + const assignerRole = await getApiKeyCreatorRole(authentication.apiKeyId, authentication.organizationId); + if (!canManageOrganizationUsers(assignerRole)) { + return handleApiError( + request, + { + type: "forbidden", + details: [{ field: "team", issue: "You are not allowed to manage teams in this organization" }], + }, + auditLog + ); + } + let oldTeamData: any = UNKNOWN_DATA; try { const oldTeamResult = await getTeam(params.organizationId, params.teamId); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.test.ts new file mode 100644 index 000000000000..1506274f685c --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client"; + +const { + mockAuthenticatedApiClient, + mockCanManageOrganizationUsers, + mockCreateTeam, + mockGetApiKeyCreatorRole, + mockHandleApiError, + mockSuccessResponse, +} = vi.hoisted(() => ({ + mockAuthenticatedApiClient: vi.fn(), + mockCanManageOrganizationUsers: vi.fn(), + mockCreateTeam: vi.fn(), + mockGetApiKeyCreatorRole: vi.fn(), + mockHandleApiError: vi.fn(), + mockSuccessResponse: vi.fn(), +})); + +vi.mock("@/modules/api/v2/auth/authenticated-api-client", () => ({ + authenticatedApiClient: mockAuthenticatedApiClient, +})); + +vi.mock("@/modules/api/v2/lib/response", () => ({ + responses: { + createdResponse: mockSuccessResponse, + successResponse: mockSuccessResponse, + }, +})); + +vi.mock("@/modules/api/v2/lib/utils", () => ({ + handleApiError: mockHandleApiError, +})); + +vi.mock("@/modules/api/v2/organizations/[organizationId]/teams/lib/teams", () => ({ + createTeam: mockCreateTeam, + getTeams: vi.fn(), +})); + +vi.mock("@/modules/api/v2/organizations/[organizationId]/users/lib/utils", () => ({ + canManageOrganizationUsers: mockCanManageOrganizationUsers, + getApiKeyCreatorRole: mockGetApiKeyCreatorRole, +})); + +const organizationId = "org123"; +const apiKeyId = "apiKey123"; +const teamInput = { name: "Test Team" }; + +const buildRequest = () => + new Request("http://localhost/api/v2/organizations/org123/teams", { method: "POST" }); + +describe("POST /organizations/[organizationId]/teams", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockAuthenticatedApiClient.mockImplementation( + async ({ handler }: Parameters[0]) => + await handler({ + request: buildRequest(), + auditLog: undefined, + authentication: { + type: "apiKey", + apiKeyId, + organizationId, + workspacePermissions: [], + organizationAccess: { accessControl: { read: true, write: true } }, + }, + parsedInput: { + body: teamInput, + params: { organizationId }, + }, + }) + ); + mockHandleApiError.mockImplementation((_request, error) => Response.json({ error }, { status: 403 })); + mockSuccessResponse.mockImplementation((body: unknown) => Response.json(body, { status: 201 })); + }); + + test("denies team creation when the API key creator can no longer manage users", async () => { + mockGetApiKeyCreatorRole.mockResolvedValue(null); + mockCanManageOrganizationUsers.mockReturnValue(false); + + const { POST } = await import("./route"); + const response = await POST(buildRequest(), { params: Promise.resolve({ organizationId }) }); + + expect(mockGetApiKeyCreatorRole).toHaveBeenCalledWith(apiKeyId, organizationId); + expect(mockCanManageOrganizationUsers).toHaveBeenCalledWith(null); + expect(mockCreateTeam).not.toHaveBeenCalled(); + expect(mockHandleApiError).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ type: "forbidden" }), + undefined + ); + expect(response.status).toBe(403); + }); + + test("creates the team when the API key creator clears the user-management floor", async () => { + mockGetApiKeyCreatorRole.mockResolvedValue("manager"); + mockCanManageOrganizationUsers.mockReturnValue(true); + mockCreateTeam.mockResolvedValue({ ok: true, data: { id: "team123", ...teamInput, organizationId } }); + + const { POST } = await import("./route"); + const response = await POST(buildRequest(), { params: Promise.resolve({ organizationId }) }); + + expect(mockGetApiKeyCreatorRole).toHaveBeenCalledWith(apiKeyId, organizationId); + expect(mockCanManageOrganizationUsers).toHaveBeenCalledWith("manager"); + expect(mockCreateTeam).toHaveBeenCalledWith(teamInput, organizationId); + expect(response.status).toBe(201); + }); +}); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts index d9fb1aff5bea..937779ec5c41 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts @@ -11,6 +11,10 @@ import { ZTeamInput, } from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams"; import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations"; +import { + canManageOrganizationUsers, + getApiKeyCreatorRole, +} from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils"; export const GET = async (request: NextRequest, props: { params: Promise<{ organizationId: string }> }) => authenticatedApiClient({ @@ -60,6 +64,21 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza ); } + // Org API keys carry no role of their own, so anchor authorization to the API key creator's + // role, mirroring the same clamp enforced on user creation. Without this, a key whose creator + // was demoted or removed could still create/rename teams even though it can no longer manage users. + const assignerRole = await getApiKeyCreatorRole(authentication.apiKeyId, authentication.organizationId); + if (!canManageOrganizationUsers(assignerRole)) { + return handleApiError( + request, + { + type: "forbidden", + details: [{ field: "team", issue: "You are not allowed to manage teams in this organization" }], + }, + auditLog + ); + } + const createTeamResult = await createTeam(body!, authentication.organizationId); if (!createTeamResult.ok) { return handleApiError(request, createTeamResult.error, auditLog); diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 53ae1409ba22..4b3bce166465 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -5,13 +5,12 @@ import { fileURLToPath } from "node:url"; // Single source of truth for image-optimizer hosts (ENG-1678); shared with the runtime // `isExternalImageSrc` check in lib/image-hosts.ts so remotePatterns and the per- // `unoptimized` decision can never drift apart. -import { OPTIMIZABLE_IMAGE_HOSTS } from "./lib/optimizable-image-hosts.mjs"; +import { LOOPBACK_HOSTS, OPTIMIZABLE_IMAGE_HOSTS } from "./lib/optimizable-image-hosts.mjs"; const jiti = createJiti(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); jiti("./lib/env"); -const LOOPBACK_HOSTS = ["localhost", "127.0.0.1"]; const LOOPBACK_WILDCARD_ORIGINS = LOOPBACK_HOSTS.map((host) => `http://${host}:*`); const getLoopbackOriginVariants = (value) => { @@ -42,9 +41,10 @@ const getUniqueValues = (values) => [...new Set(values.filter(Boolean))]; // NOTE: every `process.env.*` read in this file shapes the build output and MUST be listed in the // root turbo.json `build.env` array so Turborepo hashes it into the cache key. Adding a read here -// without updating turbo.json serves stale cached builds — locally and via the CI remote cache. -// Enforced by lib/turbo-build-env.test.ts. Read env vars directly (`process.env.` or -// `process.env[""]`), not via destructuring, so that guardrail can detect them. +// without updating turbo.json serves stale cached builds — from the local Turbo cache and the CI +// build-output cache alike. Enforced by lib/turbo-build-env.test.ts. Read env vars directly +// (`process.env.` or `process.env[""]`), not via destructuring, so that guardrail can +// detect them. /** @type {import('next').NextConfig} */ const nextConfig = { diff --git a/docs/docs.json b/docs/docs.json index c1b18a67d976..c819ba866fed 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -169,9 +169,11 @@ "icon": "wrench", "pages": [ "surveys/website-app-surveys/actions", - "surveys/website-app-surveys/advanced-targeting", "surveys/website-app-surveys/user-identification", + "surveys/website-app-surveys/attribute-based-targeting", "surveys/website-app-surveys/recontact", + "surveys/website-app-surveys/cooldown-period", + "surveys/website-app-surveys/survey-display-logic", "surveys/website-app-surveys/show-survey-to-percent-of-users" ] } @@ -850,13 +852,17 @@ "source": "/docs/app-surveys/actions" }, { - "destination": "/docs/surveys/website-app-surveys/advanced-targeting", + "destination": "/docs/surveys/website-app-surveys/attribute-based-targeting", "source": "/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting" }, { - "destination": "/docs/surveys/website-app-surveys/advanced-targeting", + "destination": "/docs/surveys/website-app-surveys/attribute-based-targeting", "source": "/docs/app-surveys/advanced-targeting" }, + { + "destination": "/docs/surveys/website-app-surveys/attribute-based-targeting", + "source": "/docs/surveys/website-app-surveys/advanced-targeting" + }, { "destination": "/docs/surveys/website-app-surveys/user-identification", "source": "/docs/xm-and-surveys/surveys/website-app-surveys/user-identification" diff --git a/docs/self-hosting/advanced/enterprise-features/contact-management-segments.mdx b/docs/self-hosting/advanced/enterprise-features/contact-management-segments.mdx index 1b2ab85fd9e6..7fa8d7167b9f 100644 --- a/docs/self-hosting/advanced/enterprise-features/contact-management-segments.mdx +++ b/docs/self-hosting/advanced/enterprise-features/contact-management-segments.mdx @@ -5,4 +5,4 @@ icon: "address-book" sidebarTitle: "Contacts & Segments" --- -Contacts are helpful if you want to assign response to specific contacts or users of your mobile application. Also, if you'd like to do [attribute-based targeting](/surveys/website-app-surveys/advanced-targeting) of specific cohorts or segments of your user base you need this feature. +Contacts are helpful if you want to assign response to specific contacts or users of your mobile application. Also, if you'd like to do [attribute-based targeting](/surveys/website-app-surveys/attribute-based-targeting) of specific cohorts or segments of your user base you need this feature. diff --git a/docs/surveys/best-practices/research-panel.mdx b/docs/surveys/best-practices/research-panel.mdx index ccd40d734b64..c8dc6ebe4c1b 100644 --- a/docs/surveys/best-practices/research-panel.mdx +++ b/docs/surveys/best-practices/research-panel.mdx @@ -139,7 +139,7 @@ Building a research panel with Formbricks involves these key steps: 4. Name your segment descriptively (e.g., "Tech SMB Professionals") 5. Save the segment - Create multiple segments for different research needs. Learn more about [Advanced Targeting](/surveys/website-app-surveys/advanced-targeting) for detailed segmentation options. + Create multiple segments for different research needs. Learn more about [Attribute-based Targeting](/surveys/website-app-surveys/attribute-based-targeting) for detailed segmentation options. @@ -193,6 +193,6 @@ Building a research panel with Formbricks involves these key steps: ## Next steps - [Personal Links](/surveys/link-surveys/personal-links) - Learn more about generating and managing personal survey links -- [Advanced Targeting](/surveys/website-app-surveys/advanced-targeting) - Explore detailed segmentation options +- [Attribute-based Targeting](/surveys/website-app-surveys/attribute-based-targeting) - Explore detailed segmentation options - [Hidden Fields](/surveys/general-features/hidden-fields) - Pass additional data into surveys via URL parameters diff --git a/docs/surveys/best-practices/understanding-survey-types.mdx b/docs/surveys/best-practices/understanding-survey-types.mdx index 429518f91953..6dce87809735 100644 --- a/docs/surveys/best-practices/understanding-survey-types.mdx +++ b/docs/surveys/best-practices/understanding-survey-types.mdx @@ -94,7 +94,7 @@ formbricks.track("action_name"); - Global waiting time (e.g., show max 1 survey every 7 days per user) - Per-survey overrides for high-priority surveys -**[Segment-based targeting](/surveys/website-app-surveys/advanced-targeting)** +**[Attribute-based Targeting](/surveys/website-app-surveys/attribute-based-targeting)** Target surveys based on user attributes (plan type, language, feature flags). diff --git a/docs/surveys/website-app-surveys/actions.mdx b/docs/surveys/website-app-surveys/actions.mdx index 9a4a55b181e6..9e90694d7f12 100644 --- a/docs/surveys/website-app-surveys/actions.mdx +++ b/docs/surveys/website-app-surveys/actions.mdx @@ -4,7 +4,7 @@ description: "Actions are predefined events within your app that prompt Formbric icon: "code" --- -## **How Do Actions Work?** +## How do Actions work? @@ -45,7 +45,7 @@ icon: "code" configurations, and survey trigger updates. -## **Setting Up No-Code Actions** +## Setting up no-code Actions Formbricks offers an intuitive No-Code interface that allows you to configure actions without needing to write any code. @@ -68,7 +68,7 @@ Formbricks offers an intuitive No-Code interface that allows you to configure ac There are four types of No-Code actions: -### **1. Click Action** +### 1. Click action ![Add click action to open source in app survey](/images/surveys/website-app-surveys/actions/click-action.webp "Add click action to open source in app survey") @@ -80,19 +80,19 @@ A Click Action is triggered when a user clicks on a specific element within your - **Both**: Only if both is true, the action is triggered -### **2. Page View Action** +### 2. Page view action ![Add page view action to open source in app survey](/images/surveys/website-app-surveys/actions/page-view.webp "Add page view action to open source in app survey") This action is triggered when a user visits a page within your application. -### **3. Exit Intent Action** +### 3. Exit intent action ![Add exit intent action to open source in app survey](/images/surveys/website-app-surveys/actions/exit-intent.webp "Add exit intent action to open source in app survey") This action is triggered when a user is about to leave your application. It helps capture user feedback before they exit, providing valuable insights into user experiences and potential improvements. -### **4. 50% Scroll Action** +### 4. 50% scroll action ![Add 50% scroll action to open source in app survey](/images/surveys/website-app-surveys/actions/scroll.webp "Add 50% scroll action to open source in app survey") @@ -102,7 +102,7 @@ This action is triggered when a user visits a specific page within your applicat You can combine the url filters with any of the no-code actions to trigger the survey based on the URL match conditions. -### **Page Filter** +### Page filter You can limit action tracking to specific subpages of your website or web app by using the Page Filter. Here you can use a variety of URL filter settings: @@ -120,7 +120,7 @@ You can limit action tracking to specific subpages of your website or web app by - **matchesRegex**: Activates when the URL matches the pattern from the specified string. -## **Setting Up Code Actions** +## Setting up code Actions For more granular control, you can implement actions directly in your code: diff --git a/docs/surveys/website-app-surveys/advanced-targeting.mdx b/docs/surveys/website-app-surveys/attribute-based-targeting.mdx similarity index 57% rename from docs/surveys/website-app-surveys/advanced-targeting.mdx rename to docs/surveys/website-app-surveys/attribute-based-targeting.mdx index 83248426cf9f..457942ba3dc8 100644 --- a/docs/surveys/website-app-surveys/advanced-targeting.mdx +++ b/docs/surveys/website-app-surveys/attribute-based-targeting.mdx @@ -1,16 +1,16 @@ --- -title: "Advanced Targeting" -description: "Advanced Targeting allows you to show surveys to a specific segment of your users. You can target surveys based on user attributes, device type, and more. This helps you get more relevant insights while keeping survey fatigue at a minimum. After the initial setup, you can target any segment without touching code." +title: "Attribute-based Targeting" +description: "Attribute-based Targeting allows you to show surveys to a specific segment of your users. You can target surveys based on user attributes, device type, and more. This helps you get more relevant insights while keeping survey fatigue at a minimum. After the initial setup, you can target any segment without touching code." icon: "bullseye" --- - Advanced Targeting is part of the [Enterprise Edition](/self-hosting/advanced/license). + Attribute-based Targeting is part of the [Enterprise Edition](/self-hosting/advanced/license). -### When to use Advanced Targeting? +### When to use Attribute-based Targeting? -Advanced Targeting helps you achieve a number of goals: +Attribute-based Targeting helps you achieve a number of goals: 1. **Relevance**: Keep survey content relevant to respondents. @@ -18,7 +18,7 @@ Advanced Targeting helps you achieve a number of goals: 3. **Statistical Relevance:** When surveying a smaller subset of users, statistical relevance is reached with a lot less responses. -## How does Advanced Targeting work? +## How does Attribute-based Targeting work? @@ -29,7 +29,7 @@ Advanced Targeting helps you achieve a number of goals: - In the Segment editor, you can configure your Segment with a combination of Attributes, Segments and Devices. If a user matches either or all of the criteria, they become part of the Segment. See [Segment Configuration](/surveys/website-app-surveys/advanced-targeting#segment-configuration) below. + In the Segment editor, you can configure your Segment with a combination of Attributes, Segments, Devices, and Survey Interactions. If a user matches either or all of the criteria, they become part of the Segment. See [Segment Configuration](/surveys/website-app-surveys/attribute-based-targeting#segment-configuration) below. @@ -48,7 +48,7 @@ Advanced Targeting helps you achieve a number of goals: ### Segment Configuration -There are three means to move Contacts in or out of Segments: **Attributes**, other **Segments** and **Devices**: +There are four means to move Contacts in or out of Segments: **Attributes**, other **Segments**, **Devices**, and [**Survey Interactions**](/surveys/website-app-surveys/survey-display-logic#level-4-interaction-based-segments): 1. **Attributes**: If the value of a specific attribute matches, the user becomes part of the Segment. @@ -64,4 +64,6 @@ There are three means to move Contacts in or out of Segments: **Attributes**, ot ![Devices filter](/images/surveys/website-app-surveys/targeting/device-filter.webp "Devices filter") -4. **Filter Groups:** You can group any of the above conditions in group and connect them logically with `AND` or `OR`. This allows for maximum granularity. \ No newline at end of file +4. **Survey Interactions**: Include or exclude Contacts based on whether they have seen, started, or completed a survey within a recent time window. See [Survey Display Logic](/surveys/website-app-surveys/survey-display-logic#level-4-interaction-based-segments) for details and use cases like per-survey cooldowns and follow-up funnels. + +5. **Filter Groups:** You can group any of the above conditions in group and connect them logically with `AND` or `OR`. This allows for maximum granularity. \ No newline at end of file diff --git a/docs/surveys/website-app-surveys/cooldown-period.mdx b/docs/surveys/website-app-surveys/cooldown-period.mdx new file mode 100644 index 000000000000..ba0f51bf0a38 --- /dev/null +++ b/docs/surveys/website-app-surveys/cooldown-period.mdx @@ -0,0 +1,39 @@ +--- +title: "Cooldown Period" +description: "The Cooldown Period is a workspace-wide guard that limits how often a user sees any survey, preventing survey fatigue across all your App Surveys." +icon: "hourglass-half" +--- + +The Cooldown Period is a universal blocker that makes sure no user sees too many surveys in a short time. It is measured **across all surveys** in a workspace, which is especially helpful when several teams use Formbricks at the same time. It is one layer of the full display logic — see [Survey Display Logic](/surveys/website-app-surveys/survey-display-logic) for how it combines with targeting, Recontact Options, and triggers. + +The default Cooldown Period is 7 days. + +## Workspace Cooldown Period + +To adjust the workspace-wide Cooldown Period: + +1. Open **Settings → Workspace → General**. +2. Find the **Cooldown Period (across surveys)** section. +3. Set the interval, in days. + +After a user sees *any* survey, no survey will be shown to them again until the Cooldown Period has elapsed. + +## Overriding the Cooldown Period for a survey + +In the Survey Editor, under **Settings → Visibility & Recontact**, each survey chooses how it interacts with the workspace Cooldown Period: + +- **Use Cooldown Period** *(default)* — the survey follows the workspace configuration and only shows if no other survey appeared during that period. +- **Ignore Cooldown Period** — the survey can show whenever its conditions are met, even if another survey was shown recently. +- **Set custom Cooldown Period** — override the workspace value for this survey only. + +## How the Cooldown Period is measured + + + The Cooldown Period is measured from the last time the user saw **any** survey — not from the last time they saw *this* survey. A per-survey value therefore changes the *length* of the gap, not what resets it: a frequently-shown survey keeps pushing back a less-frequent one. + + +To run a survey on its **own** schedule regardless of other surveys — for example an NPS survey every 3 months — don't rely on the Cooldown Period alone. Target it with an [interaction-based segment](/surveys/website-app-surveys/survey-display-logic#level-4-interaction-based-segments) such as *have not seen this survey within 3 months*, which is evaluated per survey. See [Survey Display Logic](/surveys/website-app-surveys/survey-display-logic#level-4-interaction-based-segments) for the full recipe. + +--- + +Still struggling or is something not working as expected? [Join us in GitHub Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you! diff --git a/docs/surveys/website-app-surveys/recontact.mdx b/docs/surveys/website-app-surveys/recontact.mdx index 6c0801e68645..0399ed6c0466 100644 --- a/docs/surveys/website-app-surveys/recontact.mdx +++ b/docs/surveys/website-app-surveys/recontact.mdx @@ -1,89 +1,32 @@ --- title: "Recontact Options" -description: "Recontact options in Formbricks enable you to manage how often and under what conditions a survey is shown to a user. This feature is crucial for balancing effective feedback collection with a positive user experience by preventing survey fatigue." +description: "Recontact Options control how often a single survey is shown to the same user, helping you balance effective feedback collection with a positive user experience by preventing survey fatigue." icon: "user-check" --- -## When do Recontact Options come into play? - -Recontact options are the last layer of the logic that determines if a survey is shown to the current user. The logic goes as follows: - -1. Targeting: Does the current user targeted to fill out this survey? If yes... - -2. Trigger: Is the survey triggered? If yes... - -3. **Recontact Options:** Should the survey be shown (again) to this user? That's dependent on: - -- Did the user see any survey recently (meaning, has Survey Cooldown passed)? - -- Did the user see this specific survey already? - -- How many times did the user see this specific survey already? - -- Has the user already responded to this survey? - -As you can see, there are a lot of different cases to cover. Let's have a closer look 👇 - -## Recontact Options +Recontact Options are the **per-survey** control for how often *this* survey may reappear to the same person. They are one layer of the full display logic — see [Survey Display Logic](/surveys/website-app-surveys/survey-display-logic) for how they combine with targeting, the Cooldown Period, and triggers. By default, a survey is shown to each user only once. -You can adjust the default behavior by modifying the Recontact Options for each survey in the settings: - -1. Open the Survey Editor for the survey you want to see & modify the Recontact Options for. - -2. Select the Settings Tab. - -3. Ensure your Survey type is set to **App Survey**. - -![Choose Survey Type as App Survey](/images/surveys/website-app-surveys/recontact/app-survey.webp) - -1. Scroll down to the Recontact Options section. - -Available Recontact Options include: - -- **Show only once**: (default) Displays the survey a single time, regardless of whether it was completed. - -- **Until they Submit a Response**: If tareting matches and trigger fires, Formbricks keeps showing the survey until the user submits a response. - -- **Keep Showing while Conditions Match**: Always shows the survey while specific conditions are met. Useful for continuous feedback collection, such as in [Docs Feedback Survey](/surveys/best-practices/docs-feedback) or the [Feedback Box](/surveys/best-practices/feedback-box). - -![Choose Recontanct Options for the Survey](/images/surveys/website-app-surveys/recontact/survey-recontact.webp) - -## Workspace-wide Survey Cooldown - -The Survey Cooldown is a universal blocker to make sure that no user sees too many surveys. This is particularly helpful when several teams of large organisations use Formbricks at the same time. - -The default Survey Cooldown is set to 7 days. - -To adjust the Survey Cooldown: - -1. Open **Settings → Workspace → General** from the left sidebar - -2. Find the **Recontact Waiting Time** section - -3. Modify the interval (in days) as needed. - -![Formbricks Workspace-Wide Wait Time](/images/surveys/website-app-surveys/recontact/global-wait-time.webp) - -## Overriding Survey Cooldown for a Specific Survey - -For specific surveys, you may need to override the default cooldown. Below is how you can do that: - -1. In the Survey Editor, access the Settings Tab. +## Configuring Recontact Options -2. Find the Ignore Waiting Time between Surveys toggle under Recontact Options. +1. Open the Survey Editor and select the **Settings** tab. +2. Ensure the survey type is set to **App Survey**. +3. Open the **Visibility & Recontact** section and choose an option under **Recontact options**. -3. Enable this toggle to bypass the global setting. +Available options: -4. Set a custom recontact period: +- **Show only once** *(default)* — show a single time, even if the user does not respond. +- **Show a limited number of times** — show at most the specified number of times, or until the user responds (whichever comes first). +- **Ask until they submit a response** — keep showing whenever triggered until a response or partial response is submitted. +- **Keep showing while conditions match** — allow multiple responses and keep showing even after a response. Useful for continuous feedback collection, such as the [Docs Feedback Survey](/surveys/best-practices/docs-feedback) or the [Feedback Box](/surveys/best-practices/feedback-box). - - **Always Show Survey**: Displays the survey whenever triggered, ignoring the cooldown. +Each option counts *this* survey's own displays and responses only. - - **Wait `X` days before showing this survey again**: Sets a specific interval before the survey can be shown again. +## Relationship to the Cooldown Period -![Ignore Survey Cooldown for a Specific Survey](/images/surveys/website-app-surveys/recontact/ignore-wait-time.webp) +Recontact Options decide how often a survey may repeat; the [Cooldown Period](/surveys/website-app-surveys/cooldown-period) is a separate, workspace-wide gate that limits how often a user sees *any* survey. Both must pass before a survey is shown — Recontact Options are evaluated first, then the Cooldown Period. --- -Still struggling or is something not working as expected? [Join us in Github Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you! +Still struggling or is something not working as expected? [Join us in GitHub Discussions](https://github.com/formbricks/formbricks/discussions) and we'd be glad to assist you! diff --git a/docs/surveys/website-app-surveys/survey-display-logic.mdx b/docs/surveys/website-app-surveys/survey-display-logic.mdx new file mode 100644 index 000000000000..8c2036d17d38 --- /dev/null +++ b/docs/surveys/website-app-surveys/survey-display-logic.mdx @@ -0,0 +1,161 @@ +--- +title: "Survey Display Logic" +description: "How Formbricks decides whether to show an app survey to a user — from the simplest always-on setup to Recontact Options, the workspace Cooldown Period, segment targeting, and interaction-based segments." +icon: "traffic-light" +--- + +Whether an app survey is shown to a given user comes down to a matching **trigger** plus a few independent **eligibility gates**. When a trigger fires, the survey appears only if it passes **all** of the gates. This page builds that model up one layer at a time, from the simplest case to the most advanced. + +Formbricks decides a survey's eligibility from three gates, evaluated in this order: + +1. **Recontact Options** — how often may *this* survey be shown to the same user? +2. **Cooldown Period** — has enough time passed since the user last saw *any* survey? +3. **Targeting** — is the user in the survey's audience? + +A **Trigger** (a matching action) is the event that then shows an already-eligible survey — it is not a fourth gate, it is what starts a display attempt. The gates are computed up front (on load and whenever the user's data syncs); the trigger is checked against that result. + +## Level 0 — Always show + +Take a survey with **no targeting**, **no Cooldown Period**, and Recontact set to **"Keep showing while conditions match"**. Nothing gates it, so it shows **every time its trigger fires**. Simple, but usually too aggressive for anything but a persistent feedback button. + +```mermaid +flowchart LR + T["Trigger fires"] --> S["Show survey"] +``` + +## Level 1 — Recontact Options (per survey) + +Recontact Options control how often a single survey may reappear. They count *this* survey's own displays and responses, so they are genuinely **per survey**: + +| Option | Shows the survey… | +| --- | --- | +| **Show only once** (default) | once, ever | +| **Show a limited number of times** | up to N displays, or until the user responds | +| **Ask until they submit a response** | on each trigger until the user responds | +| **Keep showing while conditions match** | on every trigger (Level 0) | + +**Example:** an onboarding survey set to *Ask until they submit a response* keeps appearing on each trigger until the user answers, then stops. + +```mermaid +flowchart LR + T["Trigger fires"] --> R{"Already shown
enough times?"} + R -->|"no"| S["Show survey"] + R -->|"yes"| X["Skip"] +``` + +See [Recontact Options](/surveys/website-app-surveys/recontact) for setup. + +## Level 2 — Cooldown Period between surveys + +On top of per-survey recontact, a **Cooldown Period** enforces a minimum gap between surveys. Two settings feed it, but they share **one clock**: + +- **Workspace Cooldown Period** (default **7 days**): after a user sees *any* survey, no survey shows again for the Cooldown Period. +- **Per-survey override** (*Ignore Cooldown Period* / *Set custom Cooldown Period*): replaces the workspace number **for that survey only**. + + + The Cooldown Period is measured from the last time the user saw **any** survey — not from the last time they saw *this* one. So a per-survey "wait 90 days" really means "90 days since the user saw *any* survey," which frequently-shown surveys keep resetting. The per-survey number changes the *value*, not the *clock*. To gate one survey on its **own** schedule, use interaction-based segments (Level 4). + + +**Example:** with the default 7-day Cooldown Period, showing any short survey today blocks *every* survey — including a quarterly NPS — for the next 7 days. + +```mermaid +flowchart LR + T["Trigger fires"] --> R{"Recontact:
shown enough?"} + R -->|"yes"| X["Skip"] + R -->|"no"| W{"Enough days since
ANY survey shown?"} + W -->|"no"| X + W -->|"yes"| S["Show survey"] +``` + +See [Cooldown Period](/surveys/website-app-surveys/cooldown-period) for setup. + +## Level 3 — Targeting with Segments + + + Targeting is part of [Attribute-based Targeting](/surveys/website-app-surveys/attribute-based-targeting), included in the [Enterprise Edition](/self-hosting/advanced/license). + + +Targeting restricts **who** is eligible. A survey can be assigned a Segment built from **Attributes**, other **Segments**, and **Devices**. Only users who match the Segment pass this layer; everyone else never sees the survey, regardless of triggers or the Cooldown Period. + +**Example:** a Segment `plan = "pro"` means only Pro users are eligible for the survey. + +```mermaid +flowchart LR + T["Trigger fires"] --> P{"Recontact &
Cooldown Period OK?"} + P -->|"no"| X["Skip"] + P -->|"yes"| S{"User in the
survey's Segment?"} + S -->|"no"| X + S -->|"yes"| Show["Show survey"] +``` + +See [Attribute-based Targeting](/surveys/website-app-surveys/attribute-based-targeting) for setup. + +## Level 4 — Interaction-based Segments + +Interaction-based segments add a fourth Segment filter type: target users by **how they interacted with your surveys** — seen, started, or completed within a recent window. Because these look at a *specific* survey's own displays and responses, they do what the shared Cooldown Period clock cannot. + +A filter reads as one sentence: + +> `` `` **within** `` `` + +### Interaction operators + +| Operator | Matches users who… | +| --- | --- | +| **have seen** | were shown the survey within the window | +| **have not seen** | were **not** shown the survey within the window | +| **have started responding to** | opened and answered at least one question (finished or not) | +| **have completed** | submitted a completed response within the window | +| **have not completed** | have **no** completed response within the window | + + + **"have started responding to" vs "have not completed" are not opposites.** *Have started* requires the user to have engaged at least once. *Have not completed* is the negation of *have completed*, so it also matches everyone who never saw or started the survey — use it as an **exclusion**, not stand-alone targeting. To target **drop-offs**, combine both with `AND`: *have started responding to A* **AND** *have not completed A*. + + +### Primary use case: a true per-survey Cooldown Period + +This is what interaction segments unlock over the built-in Cooldown Period. To show an **NPS survey on its own ~3-month cadence**, driven by that survey's own history rather than the shared cross-survey clock: + +- **Recontact:** *Keep showing while conditions match*. +- **Cooldown Period:** set a **small** custom value (e.g. *1 day*) — a short guard so it can't repeat within a session (see the note below). Do **not** use *Ignore Cooldown Period*. +- **Targeting Segment:** `have not seen NPS within 3 months` (specific survey = NPS). + +The Segment provides the real 3-month per-survey cadence; the small Cooldown Period is only a short-term guard. + +```mermaid +flowchart LR + T["Trigger fires"] --> I{"Seen NPS in the
last 3 months?"} + I -->|"yes"| X["Skip"] + I -->|"no"| S["Show NPS"] +``` + +### Other use cases + +- **Follow-up funnel:** a detail survey targets `have completed NPS within 30 days`. +- **Re-engage drop-offs:** `have started responding to Onboarding AND have not completed Onboarding within 14 days`. + + + **Interaction membership refreshes on sync, not instantly.** Recontact and the Cooldown Period react the moment a survey is shown; interaction-segment membership is recalculated when the user's data syncs (on load, on `identify`/`setAttributes`, and periodically). This lag is irrelevant for long windows like the 3-month cadence above — the short Cooldown Period guard covers it — but don't rely on interaction filters for *instant* re-display suppression, and don't pair one with *Show only once*, which already blocks re-display permanently. + + +## Putting it all together + +```mermaid +flowchart TD + A["Visitor loads or identifies"] --> B["Formbricks syncs and calculates
which Segments the visitor is in
(including survey-interaction filters)"] + B --> T{"Trigger:
does a matching action fire?"} + T -->|no| WAIT["Waits for a trigger"] + T -->|yes| R{"Recontact Options:
display count for THIS survey satisfied?"} + R -->|no| X["Survey not shown"] + R -->|yes| W{"Cooldown Period:
enough days since the last survey shown?
(per-survey value or workspace Cooldown Period)"} + W -->|no| X + W -->|yes| S{"Targeting:
is the visitor in the survey's Segment?"} + S -->|no| X + S -->|yes| G["Survey shown, display recorded"] + G -.->|"updates recontact & Cooldown Period state instantly"| R + G -.->|"changes interaction-segment membership at next sync"| B +``` + +--- + +Still have questions or seeing unexpected behavior? [Join us in GitHub Discussions](https://github.com/formbricks/formbricks/discussions) and we'll be glad to help. diff --git a/docs/unify-feedback/journey-management/detection-signals.mdx b/docs/unify-feedback/journey-management/detection-signals.mdx index f16329f15358..d03ddf5b5477 100644 --- a/docs/unify-feedback/journey-management/detection-signals.mdx +++ b/docs/unify-feedback/journey-management/detection-signals.mdx @@ -19,7 +19,7 @@ Journey Activation runs on two loops. The inner loop opens a case to recover one ## Formbricks Approach * [Actions](/surveys/website-app-surveys/actions) to trigger a survey on the exact behavior that defines the moment -* [Advanced targeting](/surveys/website-app-surveys/advanced-targeting) to fire for the right segment +* [Attribute-based targeting](/surveys/website-app-surveys/attribute-based-targeting) to fire for the right segment * [Recontact options](/surveys/website-app-surveys/recontact) to cap contact frequency, set once at the workspace level * [Feedback Datasets](/unify-feedback/feedback-datasets) and [Sources](/unify-feedback/feedback-sources) to land the signal next to the journey's other data