Compare commits

..

1 Commits

Author SHA1 Message Date
Piotr Gaczkowski
09494905fc feat: Secure public access to S3 buckets 2025-08-06 09:38:56 +02:00
63 changed files with 243 additions and 2114 deletions

View File

@@ -62,12 +62,10 @@ runs:
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
shell: bash
- run: |

View File

@@ -1,23 +1,23 @@
name: "Upload Sentry Sourcemaps"
description: "Extract sourcemaps from Docker image and upload to Sentry"
name: 'Upload Sentry Sourcemaps'
description: 'Extract sourcemaps from Docker image and upload to Sentry'
inputs:
docker_image:
description: "Docker image to extract sourcemaps from"
description: 'Docker image to extract sourcemaps from'
required: true
release_version:
description: "Sentry release version (e.g., v1.2.3)"
description: 'Sentry release version (e.g., v1.2.3)'
required: true
sentry_auth_token:
description: "Sentry authentication token"
description: 'Sentry authentication token'
required: true
environment:
description: "Sentry environment (e.g., production, staging)"
description: 'Sentry environment (e.g., production, staging)'
required: false
default: "staging"
default: 'staging'
runs:
using: "composite"
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -26,12 +26,13 @@ runs:
- name: Validate Sentry auth token
shell: bash
env:
SENTRY_TOKEN: ${{ inputs.sentry_auth_token }}
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
# Test the token by making a simple API call to Sentry
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
@@ -56,23 +57,13 @@ runs:
- name: Extract sourcemaps from Docker image
shell: bash
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}
run: |
set -euo pipefail
# Validate docker image format (basic validation)
if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then
echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'"
echo "Provided: $DOCKER_IMAGE"
exit 1
fi
echo "📦 Extracting sourcemaps from Docker image: $DOCKER_IMAGE"
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
echo "Container created with ID: $CONTAINER_ID"
# Set up cleanup function to ensure container is removed on script exit
@@ -91,7 +82,7 @@ runs:
# Exit with the original exit code to preserve script success/failure status
exit $original_exit_code
}
# Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT
@@ -122,7 +113,7 @@ runs:
with:
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: "./extracted-next/"
sourcemaps: './extracted-next/'
- name: Clean up extracted files
shell: bash

View File

@@ -106,16 +106,15 @@ jobs:
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
run: |
# Set hostname based on environment
if [[ "$ENVIRONMENT" == "production" ]]; then
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"
fi
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
# Prepare JSON payload for selective cache purge
json_payload=$(cat << EOF

View File

@@ -47,14 +47,12 @@ jobs:
- name: Build Docker Image
uses: docker/build-push-action@v6
env:
GITHUB_SHA: ${{ github.sha }}
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ env.GITHUB_SHA }}
tags: formbricks-test:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
@@ -91,9 +89,6 @@ jobs:
- name: Test Docker Image with Health Check
shell: bash
env:
GITHUB_SHA: ${{ github.sha }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
run: |
echo "🧪 Testing if the Docker image starts correctly..."
@@ -105,8 +100,8 @@ jobs:
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-d "formbricks-test:$GITHUB_SHA"
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."

View File

@@ -44,11 +44,11 @@ jobs:
- name: Generate SemVer version from branch or tag
id: generate_version
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
# Get reference name and type from environment variables
# Get reference name and type
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"

View File

@@ -9,7 +9,7 @@ on:
workflow_call:
inputs:
IS_PRERELEASE:
description: "Whether this is a prerelease (affects latest tag)"
description: 'Whether this is a prerelease (affects latest tag)'
required: false
type: boolean
default: false
@@ -52,20 +52,9 @@ jobs:
id: extract_release_tag
run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG="$GITHUB_REF"
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
# Validate the extracted tag format
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Original ref: $GITHUB_REF"
echo "Extracted tag: $TAG"
exit 1
fi
# Safely add to environment variables
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"

View File

@@ -26,23 +26,8 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate input version
env:
INPUT_VERSION: ${{ inputs.VERSION }}
run: |
set -euo pipefail
# Validate input version format (expects clean semver without 'v' prefix)
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Expected: clean version without 'v' prefix"
echo "Provided: $INPUT_VERSION"
exit 1
fi
# Store validated version in environment variable
echo "VERSION<<EOF" >> $GITHUB_ENV
echo "$INPUT_VERSION" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Extract release version
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
@@ -50,18 +35,15 @@ jobs:
version: latest
- name: Log in to GitHub Container Registry
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
run: |
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
- name: Package Helm chart
run: |
@@ -69,4 +51,4 @@ jobs:
- name: Push Helm chart to GitHub Container Registry
run: |
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts

View File

@@ -27,18 +27,10 @@ jobs:
- name: Get source branch name
id: branch-name
env:
RAW_BRANCH: ${{ github.head_ref }}
run: |
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
RAW_BRANCH="${{ github.head_ref }}"
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
# Additional validation - ensure branch name is not empty after sanitization
if [[ -z "$SOURCE_BRANCH" ]]; then
echo "❌ Error: Branch name is empty after sanitization"
echo "Original branch: $RAW_BRANCH"
exit 1
fi
# Safely add to environment variables using GitHub's recommended method
# This prevents environment variable injection attacks

View File

@@ -23,7 +23,7 @@ jobs:
upload-sourcemaps:
name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
@@ -31,13 +31,16 @@ jobs:
fetch-depth: 0
- name: Set Docker Image
run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }}
run: |
if [ -n "${{ inputs.tag_version }}" ]; then
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -121,9 +121,8 @@ describe("ProfilePage", () => {
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
// Check for IdBadge content
expect(screen.getByText("common.profile_id")).toBeInTheDocument();
expect(screen.getByText(mockUser.id)).toBeInTheDocument();
// Use a regex to match the text content, allowing for variable whitespace
expect(screen.getByText(new RegExp(`common\\.profile\\s*:\\s*${mockUser.id}`))).toBeInTheDocument(); // SettingsId
});
});

View File

@@ -5,9 +5,9 @@ import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/servi
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
@@ -103,7 +103,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isMultiOrgEnabled={isMultiOrgEnabled}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
<SettingsId title={t("common.profile")} id={user.id}></SettingsId>
</div>
)}
</PageContentWrapper>

View File

@@ -5,7 +5,7 @@ import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/lice
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -78,8 +78,8 @@ vi.mock("./components/DeleteOrganization", () => ({
DeleteOrganization: vi.fn(() => <div>DeleteOrganization</div>),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div>IdBadge</div>),
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div>SettingsId</div>),
}));
describe("Page", () => {
@@ -156,11 +156,10 @@ describe("Page", () => {
},
undefined
);
expect(IdBadge).toHaveBeenCalledWith(
expect(SettingsId).toHaveBeenCalledWith(
{
title: "common.organization_id",
id: mockEnvironmentAuth.organization.id,
label: "common.organization_id",
variant: "column",
},
undefined
);

View File

@@ -4,9 +4,9 @@ import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
@@ -70,7 +70,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
</PageContentWrapper>
);
};

View File

@@ -1,9 +1,10 @@
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { cleanup } from "@testing-library/react";
import { AnyActionArg } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import {
@@ -59,7 +60,6 @@ vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionIconMap: vi.fn(() => ({
[TSurveyQuestionTypeEnum.OpenText]: <span>OT</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: <span>MCS</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: <span>MCM</span>,
[TSurveyQuestionTypeEnum.Matrix]: <span>MX</span>,
[TSurveyQuestionTypeEnum.Address]: <span>AD</span>,
[TSurveyQuestionTypeEnum.ContactInfo]: <span>CI</span>,
@@ -104,27 +104,6 @@ vi.mock("lucide-react", () => ({
TagIcon: () => <span>Tag</span>,
}));
// Mock new dependencies
vi.mock("@/lib/response/utils", () => ({
extractChoiceIdsFromResponse: vi.fn((responseValue) => {
// Mock implementation that returns choice IDs based on response value
if (Array.isArray(responseValue)) {
return responseValue.map((_, index) => `choice-${index + 1}`);
} else if (typeof responseValue === "string") {
return [`choice-single`];
}
return [];
}),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(({ id }) => <div data-testid="id-badge">{id}</div>),
}));
vi.mock("@/modules/ui/lib/utils", () => ({
cn: vi.fn((...classes) => classes.filter(Boolean).join(" ")),
}));
const mockSurvey = {
id: "survey1",
name: "Test Survey",
@@ -157,28 +136,6 @@ const mockSurvey = {
headline: { default: "Contact Info Question" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "q5single",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Single Choice Question" },
required: false,
choices: [
{ id: "choice-1", label: { default: "Option 1" } },
{ id: "choice-2", label: { default: "Option 2" } },
{ id: "choice-3", label: { default: "Option 3" } },
],
} as unknown as TSurveyQuestion,
{
id: "q6multi",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multi Choice Question" },
required: false,
choices: [
{ id: "choice-a", label: { default: "Choice A" } },
{ id: "choice-b", label: { default: "Choice B" } },
{ id: "choice-c", label: { default: "Choice C" } },
],
} as unknown as TSurveyQuestion,
],
variables: [
{ id: "var1", name: "User Segment", type: "text" } as TSurveyVariable,
@@ -216,8 +173,6 @@ const mockResponseData = {
firstName: "John",
email: "john.doe@example.com",
hf1: "Hidden Field 1 Value",
q5single: "Option 1", // Single choice response
q6multi: ["Choice A", "Choice C"], // Multi choice response
},
variables: {
var1: "Segment A",
@@ -540,281 +495,3 @@ describe("ResponseTableColumns - Column Implementations", () => {
expect(hfColumn).toBeUndefined();
});
});
describe("ResponseTableColumns - Multiple Choice Questions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("generates two columns for multipleChoiceSingle questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q5single");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("generates two columns for multipleChoiceMulti questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q6multi");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("multipleChoiceSingle main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
test("multipleChoiceMulti main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
});
describe("ResponseTableColumns - Choice ID Columns", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("option IDs column calls extractChoiceIdsFromResponse for string response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single", type: "multipleChoiceSingle" }),
"default"
);
});
test("option IDs column calls extractChoiceIdsFromResponse for array response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
["Choice A", "Choice C"],
expect.objectContaining({ id: "q6multi", type: "multipleChoiceMulti" }),
"default"
);
});
test("option IDs column renders IdBadge components for choice IDs", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return specific choice IDs
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce(["choice-1", "choice-3"]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
// Should render something for choice IDs
expect(cellResult).toBeDefined();
// Verify that extractChoiceIdsFromResponse was called
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalled();
});
test("option IDs column returns null for non-string/array response values", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: 123 }, // Invalid type
language: "default",
},
};
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
expect(vi.mocked(extractChoiceIdsFromResponse)).not.toHaveBeenCalled();
});
test("option IDs column returns null when no choice IDs found", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Non-existent option" },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return empty array
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce([]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
});
test("option IDs column handles missing language gracefully", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: null, // No language
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single" }),
undefined
);
});
});
describe("ResponseTableColumns - Helper Functions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("question headers are properly created for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
// Test main column header
const mainHeader = mainColumn?.header?.();
expect(mainHeader).toBeDefined();
expect(mainHeader?.props?.className).toContain("flex items-center justify-between");
// Test option IDs column header
const optionHeader = optionIdsColumn?.header?.();
expect(optionHeader).toBeDefined();
expect(optionHeader?.props?.className).toContain("flex items-center justify-between");
});
test("question headers include proper icons for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
// Headers should be functions that return JSX
expect(typeof singleChoiceColumn?.header).toBe("function");
expect(typeof multiChoiceColumn?.header).toBe("function");
// Call headers to ensure they don't throw
expect(() => singleChoiceColumn?.header?.()).not.toThrow();
expect(() => multiChoiceColumn?.header?.()).not.toThrow();
});
});
describe("ResponseTableColumns - Integration Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("multiple choice questions work end-to-end with real data", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find all multiple choice related columns
const singleMainCol = columns.find((col) => (col as any).accessorKey === "q5single");
const singleIdsCol = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const multiMainCol = columns.find((col) => (col as any).accessorKey === "q6multi");
const multiIdsCol = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
expect(singleMainCol).toBeDefined();
expect(singleIdsCol).toBeDefined();
expect(multiMainCol).toBeDefined();
expect(multiIdsCol).toBeDefined();
// Test with actual mock response data
const mockRow = { original: mockResponseData };
// Test single choice main column
const singleMainResult = (singleMainCol?.cell as any)?.({ row: mockRow });
expect(singleMainResult).toBeDefined();
// Test multi choice main column
const multiMainResult = (multiMainCol?.cell as any)?.({ row: mockRow });
expect(multiMainResult).toBeDefined();
// Test that choice ID columns exist and can be called
const singleIdsResult = (singleIdsCol?.cell as any)?.({ row: mockRow });
const multiIdsResult = (multiIdsCol?.cell as any)?.({ row: mockRow });
// Should not error when calling the cell functions
expect(() => singleIdsResult).not.toThrow();
expect(() => multiIdsResult).not.toThrow();
});
});

View File

@@ -1,7 +1,6 @@
"use client";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
@@ -9,10 +8,8 @@ import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { ColumnDef } from "@tanstack/react-table";
import { TFnType } from "@tolgee/react";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
@@ -64,42 +61,6 @@ const getQuestionColumnsData = (
t: TFnType
): ColumnDef<TResponseTableData>[] => {
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
// Helper function to create consistent column headers
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline;
const QuestionHeader = () => (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
<span className="truncate">{title}</span>
</div>
</div>
);
QuestionHeader.displayName = "QuestionHeader";
return QuestionHeader;
};
// Helper function to get localized question headline
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
};
// Helper function to render choice ID badges
const renderChoiceIdBadges = (choiceIds: string[], isExpanded: boolean) => {
if (choiceIds.length === 0) return null;
const containerClasses = cn("flex gap-x-1 w-full", isExpanded && "flex-wrap gap-y-1");
return (
<div className={containerClasses}>
{choiceIds.map((choiceId, index) => (
<IdBadge key={choiceId || index} id={choiceId} />
))}
</div>
);
};
switch (question.type) {
case "matrix":
return question.rows.map((matrixRow) => {
@@ -176,49 +137,6 @@ const getQuestionColumnsData = (
};
});
case "multipleChoiceMulti":
case "multipleChoiceSingle":
case "ranking":
case "pictureSelection": {
const questionHeadline = getQuestionHeadline(question, survey);
return [
{
accessorKey: question.id,
header: createQuestionHeader(question.type, questionHeadline),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
const language = row.original.language;
return (
<RenderResponse
question={question}
survey={survey}
responseData={responseValue}
language={language}
isExpanded={isExpanded}
/>
);
},
},
{
accessorKey: question.id + "optionIds",
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
// Type guard to ensure responseValue is the correct type
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
const choiceIds = extractChoiceIdsFromResponse(
responseValue,
question,
row.original.language || undefined
);
return renderChoiceIdBadges(choiceIds, isExpanded);
}
return null;
},
},
];
}
default:
return [
{
@@ -399,6 +317,7 @@ export const generateResponseTableColumns = (
};
// Combine the selection column with the dynamic question columns
const baseColumns = [
personColumn,
dateColumn,

View File

@@ -27,10 +27,10 @@ vi.mock("@/modules/survey/lib/questions", () => ({
],
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ label, id }: { label: string; id: string }) => (
<div data-testid="id-badge">
{label}: {id}
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: { title: string; id: string }) => (
<div data-testid="settings-id">
{title}: {id}
</div>
),
}));
@@ -76,7 +76,7 @@ describe("QuestionSummaryHeader", () => {
).toBeInTheDocument();
expect(screen.getByTestId("question-icon")).toBeInTheDocument();
expect(screen.getByTestId("id-badge")).toHaveTextContent("common.question_id: q1");
expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1");
expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument();
});

View File

@@ -3,7 +3,7 @@
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import type { JSX } from "react";
@@ -55,7 +55,7 @@ export const QuestionSummaryHeader = ({
</div>
)}
</div>
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
<SettingsId title={t("common.question_id")} id={questionSummary.question.id}></SettingsId>
</div>
);
};

View File

@@ -98,8 +98,8 @@ vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ children }) => <div data-testid="page-header">{children}</div>),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div data-testid="id-badge"></div>),
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div data-testid="settings-id"></div>),
}));
vi.mock("@/tolgee/server", () => ({
@@ -227,7 +227,7 @@ describe("SurveyPage", () => {
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("survey-analysis-navigation")).toBeInTheDocument();
expect(screen.getByTestId("summary-page")).toBeInTheDocument();
expect(screen.getByTestId("id-badge")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toBeInTheDocument();
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);

View File

@@ -9,9 +9,9 @@ import { getUser } from "@/lib/user/service";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { notFound } from "next/navigation";
@@ -74,7 +74,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
initialSurveySummary={initialSurveySummary}
/>
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
<SettingsId title={t("common.survey_id")} id={surveyId} />
</PageContentWrapper>
);
};

View File

@@ -1,17 +1,11 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { TResponse } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyOpenTextQuestion,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
buildWhereClause,
calculateTtcTotal,
extracMetadataKeys,
extractChoiceIdsFromResponse,
extractSurveyDetails,
generateAllPermutationsOfSubsets,
getResponseContactAttributes,
@@ -561,176 +555,3 @@ describe("Response Utils", () => {
});
});
});
describe("extractChoiceIdsFromResponse", () => {
const multipleChoiceMultiQuestion: TSurveyQuestion = {
id: "multi-choice-id",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Select multiple options" },
required: false,
choices: [
{
id: "choice-1",
label: { default: "Option 1", es: "Opción 1" },
},
{
id: "choice-2",
label: { default: "Option 2", es: "Opción 2" },
},
{
id: "choice-3",
label: { default: "Option 3", es: "Opción 3" },
},
],
};
const multipleChoiceSingleQuestion: TSurveyQuestion = {
id: "single-choice-id",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Select one option" },
required: false,
choices: [
{
id: "choice-a",
label: { default: "Choice A", fr: "Choix A" },
},
{
id: "choice-b",
label: { default: "Choice B", fr: "Choix B" },
},
],
};
const textQuestion: TSurveyOpenTextQuestion = {
id: "text-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "What do you think?" },
required: false,
inputType: "text",
charLimit: { enabled: false, min: 0, max: 0 },
};
describe("multipleChoiceMulti questions", () => {
test("should extract choice IDs from array response with default language", () => {
const responseValue = ["Option 1", "Option 3"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "choice-3"]);
});
test("should extract choice IDs from array response with specific language", () => {
const responseValue = ["Opción 1", "Opción 2"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es");
expect(result).toEqual(["choice-1", "choice-2"]);
});
test("should fall back to checking all language values when exact language match fails", () => {
const responseValue = ["Opción 1", "Option 2"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "choice-2"]);
});
test("should render other option when non-matching choice is selected", () => {
const responseValue = ["Option 1", "Non-existent option", "Option 3"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "other", "choice-3"]);
});
test("should return empty array for empty response", () => {
const responseValue: string[] = [];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
});
describe("multipleChoiceSingle questions", () => {
test("should extract choice ID from string response with default language", () => {
const responseValue = "Choice A";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual(["choice-a"]);
});
test("should extract choice ID from string response with specific language", () => {
const responseValue = "Choix B";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "fr");
expect(result).toEqual(["choice-b"]);
});
test("should fall back to checking all language values for single choice", () => {
const responseValue = "Choix A";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual(["choice-a"]);
});
test("should return empty array for empty string response", () => {
const responseValue = "";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual([]);
});
});
describe("edge cases", () => {
test("should return empty array for non-multiple choice questions", () => {
const responseValue = "Some text response";
const result = extractChoiceIdsFromResponse(responseValue, textQuestion, "default");
expect(result).toEqual([]);
});
test("should handle missing language parameter by defaulting to 'default'", () => {
const responseValue = "Option 1";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion);
expect(result).toEqual(["choice-1"]);
});
test("should handle numeric or other types by returning empty array", () => {
const responseValue = 123;
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
test("should handle object responses by returning empty array", () => {
const responseValue = { invalid: "object" };
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
});
describe("language handling", () => {
test("should use provided language parameter", () => {
const responseValue = ["Opción 1"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es");
expect(result).toEqual(["choice-1"]);
});
test("should handle null language parameter by defaulting to 'default'", () => {
const responseValue = ["Option 1"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, null as any);
expect(result).toEqual(["choice-1"]);
});
test("should handle undefined language parameter by defaulting to 'default'", () => {
const responseValue = ["Option 1"];
const result = extractChoiceIdsFromResponse(
responseValue,
multipleChoiceMultiQuestion,
undefined as any
);
expect(result).toEqual(["choice-1"]);
});
});
});

View File

@@ -1,86 +1,20 @@
import "server-only";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { Prisma } from "@prisma/client";
import {
TResponse,
TResponseDataValue,
TResponseFilterCriteria,
TResponseHiddenFieldsFilter,
TResponseTtc,
TSurveyContactAttributes,
TSurveyMetaFieldFilter,
} from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { processResponseData } from "../responses";
import { getTodaysDateTimeFormatted } from "../time";
import { getFormattedDateTimeString } from "../utils/datetime";
import { sanitizeString } from "../utils/strings";
/**
* Extracts choice IDs from response values for multiple choice questions
* @param responseValue - The response value (string for single choice, array for multi choice)
* @param question - The survey question containing choices
* @param language - The language to match against (defaults to "default")
* @returns Array of choice IDs
*/
export const extractChoiceIdsFromResponse = (
responseValue: TResponseDataValue,
question: TSurveyQuestion,
language: string = "default"
): string[] => {
// Type guard to ensure the question has choices
if (
question.type !== "multipleChoiceMulti" &&
question.type !== "multipleChoiceSingle" &&
question.type !== "ranking" &&
question.type !== "pictureSelection"
) {
return [];
}
const isPictureSelection = question.type === "pictureSelection";
if (!responseValue) {
return [];
}
// For picture selection questions, the response value is already choice ID(s)
if (isPictureSelection) {
if (Array.isArray(responseValue)) {
// Multi-selection: array of choice IDs
return responseValue.filter((id): id is string => typeof id === "string");
} else if (typeof responseValue === "string") {
// Single selection: single choice ID
return [responseValue];
}
return [];
}
const defaultLanguage = language ?? "default";
// Helper function to find choice by label - eliminates duplication
const findChoiceByLabel = (choiceLabel: string): string | null => {
const targetChoice = question.choices.find((c) => {
// Try exact language match first
if (c.label[defaultLanguage] === choiceLabel) {
return true;
}
// Fall back to checking all language values
return Object.values(c.label).includes(choiceLabel);
});
return targetChoice?.id || "other";
};
if (Array.isArray(responseValue)) {
// Multiple choice case - response is an array of selected choice labels
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
} else if (typeof responseValue === "string") {
// Single choice case - response is a single choice label
const choiceId = findChoiceByLabel(responseValue);
return choiceId ? [choiceId] : [];
}
return [];
};
export const calculateTtcTotal = (ttc: TResponseTtc) => {
const result = { ...ttc };
result._total = Object.values(result).reduce((acc: number, val: number) => acc + val, 0);
@@ -556,17 +490,10 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
return question.rows.map((row) => {
return `${idx + 1}. ${headline} - ${getLocalizedValue(row, "default")}`;
});
} else if (
question.type === "multipleChoiceMulti" ||
question.type === "multipleChoiceSingle" ||
question.type === "ranking"
) {
return [`${idx + 1}. ${headline}`, `${idx + 1}. ${headline} - Option ID`];
} else {
return [`${idx + 1}. ${headline}`];
}
});
const hiddenFields = survey.hiddenFields?.fieldIds || [];
const userAttributes =
survey.type === "app"
@@ -629,19 +556,6 @@ export const getResponsesJson = (
}
}
});
} else if (
question.type === "multipleChoiceMulti" ||
question.type === "multipleChoiceSingle" ||
question.type === "ranking"
) {
// Set the main response value
jsonData[idx][questionHeadline[0]] = processResponseData(answer);
// Set the option IDs using the reusable function
if (questionHeadline[1]) {
const choiceIds = extractChoiceIdsFromResponse(answer, question, response.language || "default");
jsonData[idx][questionHeadline[1]] = choiceIds.join(", ");
}
} else {
jsonData[idx][questionHeadline[0]] = processResponseData(answer);
}

View File

@@ -1,7 +1,7 @@
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
import { isStringUrl, isValidCallbackUrl, testURLmatch } from "./url";
import { isValidCallbackUrl, testURLmatch } from "./url";
afterEach(() => {
cleanup();
@@ -91,13 +91,3 @@ describe("isValidCallbackUrl", () => {
expect(isValidCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBe(false);
});
});
describe("isStringUrl", () => {
test("returns true for valid URL", () => {
expect(isStringUrl("https://example.com")).toBe(true);
});
test("returns false for invalid URL", () => {
expect(isStringUrl("not-a-valid-url")).toBe(false);
});
});

View File

@@ -49,12 +49,3 @@ export const isValidCallbackUrl = (url: string, WEBAPP_URL: string): boolean =>
return false;
}
};
export const isStringUrl = (url: string): boolean => {
try {
new URL(url);
return true;
} catch {
return false;
}
};

View File

@@ -124,7 +124,6 @@
"add_action": "Aktion hinzufügen",
"add_filter": "Filter hinzufügen",
"add_logo": "Logo hinzufügen",
"add_member": "Mitglied hinzufügen",
"add_project": "Projekt hinzufügen",
"add_to_team": "Zum Team hinzufügen",
"all": "Alle",
@@ -280,8 +279,6 @@
"on": "An",
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
"option_id": "Option-ID",
"option_ids": "Option-IDs",
"or": "oder",
"organization": "Organisation",
"organization_id": "Organisations-ID",
@@ -308,7 +305,6 @@
"privacy": "Datenschutz",
"product_manager": "Produktmanager",
"profile": "Profil",
"profile_id": "Profil-ID",
"project_configuration": "Projektkonfiguration",
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"project_id": "Projekt-ID",
@@ -389,7 +385,6 @@
"targeting": "Targeting",
"team": "Team",
"team_access": "Teamzugriff",
"team_id": "Team-ID",
"team_name": "Teamname",
"teams": "Zugriffskontrolle",
"teams_not_found": "Teams nicht gefunden",
@@ -1316,7 +1311,7 @@
"columns": "Spalten",
"company": "Firma",
"company_logo": "Firmenlogo",
"completed_responses": "unvollständige oder vollständige Antworten.",
"completed_responses": "abgeschlossene Antworten",
"concat": "Verketten +",
"conditional_logic": "Bedingte Logik",
"confirm_default_language": "Standardsprache bestätigen",

View File

@@ -124,7 +124,6 @@
"add_action": "Add action",
"add_filter": "Add filter",
"add_logo": "Add logo",
"add_member": "Add member",
"add_project": "Add project",
"add_to_team": "Add to team",
"all": "All",
@@ -280,8 +279,6 @@
"on": "On",
"only_one_file_allowed": "Only one file is allowed",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
"option_id": "Option ID",
"option_ids": "Option IDs",
"or": "or",
"organization": "Organization",
"organization_id": "Organization ID",
@@ -308,7 +305,6 @@
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"profile": "Profile",
"profile_id": "Profile ID",
"project_configuration": "Project's Configuration",
"project_creation_description": "Organize surveys in projects for better access control.",
"project_id": "Project ID",
@@ -389,7 +385,6 @@
"targeting": "Targeting",
"team": "Team",
"team_access": "Team Access",
"team_id": "Team ID",
"team_name": "Team name",
"teams": "Access Control",
"teams_not_found": "Teams not found",
@@ -1316,7 +1311,7 @@
"columns": "Columns",
"company": "Company",
"company_logo": "Company logo",
"completed_responses": "partial or completed responses.",
"completed_responses": "completed responses.",
"concat": "Concat +",
"conditional_logic": "Conditional Logic",
"confirm_default_language": "Confirm default language",

View File

@@ -124,7 +124,6 @@
"add_action": "Ajouter une action",
"add_filter": "Ajouter un filtre",
"add_logo": "Ajouter un logo",
"add_member": "Ajouter un membre",
"add_project": "Ajouter un projet",
"add_to_team": "Ajouter à l'équipe",
"all": "Tout",
@@ -280,8 +279,6 @@
"on": "Sur",
"only_one_file_allowed": "Un seul fichier est autorisé",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
"option_id": "Identifiant de l'option",
"option_ids": "Identifiants des options",
"or": "ou",
"organization": "Organisation",
"organization_id": "ID de l'organisation",
@@ -308,7 +305,6 @@
"privacy": "Politique de confidentialité",
"product_manager": "Chef de produit",
"profile": "Profil",
"profile_id": "Identifiant de profil",
"project_configuration": "Configuration du projet",
"project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.",
"project_id": "ID de projet",
@@ -389,7 +385,6 @@
"targeting": "Ciblage",
"team": "Équipe",
"team_access": "Accès Équipe",
"team_id": "Équipe ID",
"team_name": "Nom de l'équipe",
"teams": "Contrôle d'accès",
"teams_not_found": "Équipes non trouvées",
@@ -1207,7 +1202,7 @@
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques:",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
"add": "Ajouter +",
"add_a_delay_or_auto_close_the_survey": "Ajouter un délai ou fermer automatiquement l'enquête",
"add_a_four_digit_pin": "Ajoutez un code PIN à quatre chiffres.",
@@ -1316,7 +1311,7 @@
"columns": "Colonnes",
"company": "Société",
"company_logo": "Logo de l'entreprise",
"completed_responses": "des réponses partielles ou complètes.",
"completed_responses": "réponses complètes.",
"concat": "Concat +",
"conditional_logic": "Logique conditionnelle",
"confirm_default_language": "Confirmer la langue par défaut",

View File

@@ -124,7 +124,6 @@
"add_action": "Adicionar ação",
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logo",
"add_member": "Adicionar membro",
"add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipe",
"all": "Todos",
@@ -280,8 +279,6 @@
"on": "ligado",
"only_one_file_allowed": "É permitido apenas um arquivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
"option_id": "ID da opção",
"option_ids": "IDs da Opção",
"or": "ou",
"organization": "organização",
"organization_id": "ID da Organização",
@@ -308,7 +305,6 @@
"privacy": "Política de Privacidade",
"product_manager": "Gerente de Produto",
"profile": "Perfil",
"profile_id": "ID de Perfil",
"project_configuration": "Configuração do Projeto",
"project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
"project_id": "ID do Projeto",
@@ -389,7 +385,6 @@
"targeting": "mirando",
"team": "Time",
"team_access": "Acesso da equipe",
"team_id": "ID da Equipe",
"team_name": "Nome da equipe",
"teams": "Controle de Acesso",
"teams_not_found": "Equipes não encontradas",
@@ -1316,7 +1311,7 @@
"columns": "colunas",
"company": "empresa",
"company_logo": "Logo da empresa",
"completed_responses": "respostas parciais ou completas.",
"completed_responses": "respostas completas",
"concat": "Concatenar +",
"conditional_logic": "Lógica Condicional",
"confirm_default_language": "Confirmar idioma padrão",

View File

@@ -124,7 +124,6 @@
"add_action": "Adicionar ação",
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logótipo",
"add_member": "Adicionar membro",
"add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipa",
"all": "Todos",
@@ -280,8 +279,6 @@
"on": "Ligado",
"only_one_file_allowed": "Apenas um ficheiro é permitido",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
"option_id": "ID de Opção",
"option_ids": "IDs de Opção",
"or": "ou",
"organization": "Organização",
"organization_id": "ID da Organização",
@@ -308,7 +305,6 @@
"privacy": "Política de Privacidade",
"product_manager": "Gestor de Produto",
"profile": "Perfil",
"profile_id": "ID do Perfil",
"project_configuration": "Configuração do Projeto",
"project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.",
"project_id": "ID do Projeto",
@@ -389,7 +385,6 @@
"targeting": "Segmentação",
"team": "Equipa",
"team_access": "Acesso da Equipa",
"team_id": "ID da Equipa",
"team_name": "Nome da equipa",
"teams": "Controlo de Acesso",
"teams_not_found": "Equipas não encontradas",
@@ -1316,7 +1311,7 @@
"columns": "Colunas",
"company": "Empresa",
"company_logo": "Logotipo da empresa",
"completed_responses": "respostas parciais ou completas",
"completed_responses": "respostas concluídas",
"concat": "Concatenar +",
"conditional_logic": "Lógica Condicional",
"confirm_default_language": "Confirmar idioma padrão",

View File

@@ -124,7 +124,6 @@
"add_action": "新增操作",
"add_filter": "新增篩選器",
"add_logo": "新增標誌",
"add_member": "新增成員",
"add_project": "新增專案",
"add_to_team": "新增至團隊",
"all": "全部",
@@ -280,8 +279,6 @@
"on": "開啟",
"only_one_file_allowed": "僅允許一個檔案",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
"option_id": "選項 ID",
"option_ids": "選項 IDs",
"or": "或",
"organization": "組織",
"organization_id": "組織 ID",
@@ -308,7 +305,6 @@
"privacy": "隱私權政策",
"product_manager": "產品經理",
"profile": "個人資料",
"profile_id": "個人資料 ID",
"project_configuration": "專案組態",
"project_creation_description": "組織調查 在 專案中以便更好地存取控制。",
"project_id": "專案 ID",
@@ -389,7 +385,6 @@
"targeting": "目標設定",
"team": "團隊",
"team_access": "團隊存取權限",
"team_id": "團隊 ID",
"team_name": "團隊名稱",
"teams": "存取控制",
"teams_not_found": "找不到團隊",
@@ -1316,7 +1311,7 @@
"columns": "欄位",
"company": "公司",
"company_logo": "公司標誌",
"completed_responses": "部分或完整答复。",
"completed_responses": "完成的回應。",
"concat": "串連 +",
"conditional_logic": "條件邏輯",
"confirm_default_language": "確認預設語言",

View File

@@ -324,7 +324,6 @@ export const authOptions: NextAuthOptions = {
if (account?.provider === "credentials" || account?.provider === "token") {
// check if user's email is verified or not
if (!user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
await updateUserLastLoginAt(user.email);

View File

@@ -2,7 +2,6 @@ import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants";
import { ActionClientCtx, AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { deepDiff, redactPII } from "@/lib/utils/logger-helpers";
import { logAuditEvent } from "@/modules/ee/audit-logs/lib/service";
import {
TActor,
@@ -14,6 +13,7 @@ import {
} from "@/modules/ee/audit-logs/types/audit-log";
import { getIsAuditLogsEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger";
import { deepDiff, redactPII } from "./utils";
/**
* Builds an audit event and logs it.

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { deepDiff, redactPII, sanitizeUrlForLogging } from "./logger-helpers";
import { deepDiff, redactPII } from "./utils";
// Patch redis multi before any imports
beforeEach(async () => {
@@ -104,7 +104,7 @@ describe("withAuditLogging", () => {
});
test("logs audit event for successful handler", async () => {
const handler = vi.fn().mockResolvedValue("ok");
const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler");
const { withAuditLogging } = await import("./handler");
const wrapped = withAuditLogging("created", "survey", handler);
const ctx = {
user: {
@@ -143,7 +143,7 @@ describe("withAuditLogging", () => {
});
test("logs audit event for failed handler and throws", async () => {
const handler = vi.fn().mockRejectedValue(new Error("fail"));
const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler");
const { withAuditLogging } = await import("./handler");
const wrapped = withAuditLogging("created", "survey", handler);
const ctx = {
user: {
@@ -181,37 +181,3 @@ describe("withAuditLogging", () => {
expect(handler).toHaveBeenCalled();
});
});
describe("sanitizeUrlForLogging", () => {
test("returns sanitized URL with token", () => {
expect(sanitizeUrlForLogging("https://example.com?token=1234567890")).toBe(
"https://example.com/?token=********"
);
});
test("returns sanitized URL with code", () => {
expect(sanitizeUrlForLogging("https://example.com?code=1234567890")).toBe(
"https://example.com/?code=********"
);
});
test("returns sanitized URL with state", () => {
expect(sanitizeUrlForLogging("https://example.com?state=1234567890")).toBe(
"https://example.com/?state=********"
);
});
test("returns sanitized URL with multiple keys", () => {
expect(
sanitizeUrlForLogging("https://example.com?token=1234567890&code=1234567890&state=1234567890")
).toBe("https://example.com/?token=********&code=********&state=********");
});
test("returns sanitized URL without query params", () => {
expect(sanitizeUrlForLogging("https://example.com")).toBe("https://example.com/");
});
test("returns sanitized URL with invalid URL", () => {
expect(sanitizeUrlForLogging("not-a-valid-url")).toBe("[invalid-url]");
});
});

View File

@@ -1,5 +1,3 @@
import { isStringUrl } from "@/lib/utils/url";
const SENSITIVE_KEYS = [
"email",
"name",
@@ -35,11 +33,8 @@ const SENSITIVE_KEYS = [
"image",
"stripeCustomerId",
"fileName",
"state",
];
const URL_SENSITIVE_KEYS = ["token", "code", "state"];
/**
* Redacts sensitive data from the object by replacing the sensitive keys with "********".
* @param obj - The object to redact.
@@ -50,10 +45,6 @@ export const redactPII = (obj: any, seen: WeakSet<any> = new WeakSet()): any =>
return obj.toISOString();
}
if (typeof obj === "string" && isStringUrl(obj)) {
return sanitizeUrlForLogging(obj);
}
if (obj && typeof obj === "object") {
if (seen.has(obj)) return "[Circular]";
seen.add(obj);
@@ -98,24 +89,3 @@ export const deepDiff = (oldObj: any, newObj: any): any => {
}
return Object.keys(diff).length > 0 ? diff : undefined;
};
/**
* Sanitizes a URL for logging by redacting sensitive parameters.
* @param url - The URL to sanitize.
* @returns The sanitized URL.
*/
export const sanitizeUrlForLogging = (url: string): string => {
try {
const urlObj = new URL(url);
URL_SENSITIVE_KEYS.forEach((key) => {
if (urlObj.searchParams.has(key)) {
urlObj.searchParams.set(key, "********");
}
});
return urlObj.origin + urlObj.pathname + (urlObj.search ? `${urlObj.search}` : "");
} catch {
return "[invalid-url]";
}
};

View File

@@ -2,7 +2,6 @@ import { getResponsesByContactId } from "@/lib/response/service";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { getTranslate } from "@/tolgee/server";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
@@ -43,7 +42,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<dt className="text-sm font-medium text-slate-500">{t("common.user_id")}</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.userId ? (
<IdBadge id={attributes.userId} />
<span>{attributes.userId}</span>
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}

View File

@@ -2,7 +2,6 @@
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ColumnDef } from "@tanstack/react-table";
import { TContactTableData } from "../types/contact";
@@ -27,7 +26,7 @@ export const generateContactTableColumns = (
header: "User ID",
cell: ({ row }) => {
const userId = row.original.userId;
return <IdBadge id={userId} showCopyIconOnHover={true} />;
return <HighlightedText value={userId} searchValue={searchValue} />;
},
};

View File

@@ -1,7 +1,6 @@
"use client";
import { convertDateTimeStringShort } from "@/lib/time";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
import { useTranslate } from "@tolgee/react";
import { TSegment } from "@formbricks/types/segment";
@@ -53,7 +52,10 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
</p>
</div>
<div>
<IdBadge id={currentSegment.id} label={t("environments.segments.segment_id")} variant="column" />
<Label className="text-xs font-normal text-slate-500">
{t("environments.segments.segment_id")}
</Label>
<p className="text-xs text-slate-700">{currentSegment.id.toString()}</p>
</div>
</div>
</div>

View File

@@ -4,7 +4,6 @@ import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { redactPII } from "@/lib/utils/logger-helpers";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
@@ -32,36 +31,12 @@ export const handleSsoCallback = async ({
account: Account;
callbackUrl: string;
}) => {
const contextLogger = logger.withContext({
correlationId: crypto.randomUUID(),
name: "formbricks",
});
contextLogger.debug(
{
...redactPII({ user, account, callbackUrl }),
hasEmail: !!user.email,
hasName: !!user.name,
},
"SSO callback initiated"
);
const isSsoEnabled = await getIsSsoEnabled();
if (!isSsoEnabled) {
contextLogger.debug({ isSsoEnabled }, "SSO not enabled");
return false;
}
if (!user.email || account.type !== "oauth") {
contextLogger.debug(
{
hasEmail: !!user.email,
accountType: account.type,
reason: !user.email ? "missing_email" : "invalid_account_type",
},
"SSO callback rejected: missing email or invalid account type"
);
return false;
}
@@ -70,18 +45,12 @@ export const handleSsoCallback = async ({
if (provider === "saml") {
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
if (!isSamlSsoEnabled) {
contextLogger.debug({ provider: "saml" }, "SSO callback rejected: SAML not enabled in license");
return false;
}
}
if (account.provider) {
// check if accounts for this provider / account Id already exists
contextLogger.debug(
{ lookupType: "sso_provider_account" },
"Checking for existing user with SSO provider"
);
const existingUserWithAccount = await prisma.user.findFirst({
include: {
accounts: {
@@ -97,29 +66,12 @@ export const handleSsoCallback = async ({
});
if (existingUserWithAccount) {
contextLogger.debug(
{
existingUserId: existingUserWithAccount.id,
emailMatches: existingUserWithAccount.email === user.email,
},
"Found existing user with SSO provider"
);
// User with this provider found
// check if email still the same
if (existingUserWithAccount.email === user.email) {
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"SSO callback successful: existing user, email matches"
);
return true;
}
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"Email changed in SSO provider, checking for conflicts"
);
// user seemed to change his email within the provider
// check if user with this email already exist
// if not found just update user with new email address
@@ -127,20 +79,9 @@ export const handleSsoCallback = async ({
const otherUserWithEmail = await getUserByEmail(user.email);
if (!otherUserWithEmail) {
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id, action: "email_update" },
"No other user with this email found, updating user email after SSO provider change"
);
await updateUser(existingUserWithAccount.id, { email: user.email });
return true;
}
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id, conflictingUserId: otherUserWithEmail.id },
"SSO callback failed: email conflict after provider change"
);
throw new Error(
"Looks like you updated your email somewhere else. A user with this new email exists already."
);
@@ -149,24 +90,13 @@ export const handleSsoCallback = async ({
// There is no existing account for this identity provider / account id
// check if user account with this email already exists
// if user already exists throw error and request password login
contextLogger.debug({ lookupType: "email" }, "No existing SSO account found, checking for user by email");
const existingUserWithEmail = await getUserByEmail(user.email);
if (existingUserWithEmail) {
contextLogger.debug(
{ existingUserId: existingUserWithEmail.id, action: "existing_user_login" },
"SSO callback successful: existing user found by email"
);
// Sign in the user with the existing account
return true;
}
contextLogger.debug(
{ action: "new_user_creation" },
"No existing user found, proceeding with new user creation"
);
let userName = user.name;
if (provider === "openid") {
@@ -178,16 +108,6 @@ export const handleSsoCallback = async ({
} else if (oidcUser.preferred_username) {
userName = oidcUser.preferred_username;
}
contextLogger.debug(
{
hasName: !!oidcUser.name,
hasGivenName: !!oidcUser.given_name,
hasFamilyName: !!oidcUser.family_name,
hasPreferredUsername: !!oidcUser.preferred_username,
},
"Extracted OIDC user name"
);
}
if (provider === "saml") {
@@ -197,14 +117,6 @@ export const handleSsoCallback = async ({
} else if (samlUser.firstName || samlUser.lastName) {
userName = `${samlUser.firstName} ${samlUser.lastName}`;
}
contextLogger.debug(
{
hasName: !!samlUser.name,
hasFirstName: !!samlUser.firstName,
hasLastName: !!samlUser.lastName,
},
"Extracted SAML user name"
);
}
// Get multi-org license status
@@ -212,23 +124,9 @@ export const handleSsoCallback = async ({
const isFirstUser = await getIsFreshInstance();
contextLogger.debug(
{
isMultiOrgEnabled,
isFirstUser,
skipInviteForSso: SKIP_INVITE_FOR_SSO,
hasDefaultTeamId: !!DEFAULT_TEAM_ID,
},
"License and instance configuration checked"
);
// Additional security checks for self-hosted instances without auto-provisioning and no multi-org enabled
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
if (!callbackUrl) {
contextLogger.debug(
{ reason: "missing_callback_url" },
"SSO callback rejected: missing callback URL for invite validation"
);
return false;
}
@@ -241,10 +139,6 @@ export const handleSsoCallback = async ({
// Allow sign-in if multi-org is enabled, otherwise check for invite token
if (source === "signin" && !inviteToken) {
contextLogger.debug(
{ reason: "signin_without_invite_token" },
"SSO callback rejected: signin without invite token"
);
return false;
}
@@ -252,32 +146,16 @@ export const handleSsoCallback = async ({
// Verify invite token and check email match
const { email, inviteId } = verifyInviteToken(inviteToken);
if (email !== user.email) {
contextLogger.debug(
{ reason: "invite_email_mismatch", inviteId },
"SSO callback rejected: invite token email mismatch"
);
return false;
}
// Check if invite token is still valid
const isValidInviteToken = await getIsValidInviteToken(inviteId);
if (!isValidInviteToken) {
contextLogger.debug(
{ reason: "invalid_invite_token", inviteId },
"SSO callback rejected: invalid or expired invite token"
);
return false;
}
contextLogger.debug({ inviteId }, "Invite token validation successful");
} catch (err) {
contextLogger.debug(
{
reason: "invite_token_validation_error",
error: err instanceof Error ? err.message : "unknown_error",
},
"SSO callback rejected: invite token validation failed"
);
// Log and reject on any validation errors
contextLogger.error(err, "Invalid callbackUrl");
logger.error(err, "Invalid callbackUrl");
return false;
}
}
@@ -285,12 +163,6 @@ export const handleSsoCallback = async ({
let organization: Organization | null = null;
if (!isFirstUser && !isMultiOrgEnabled) {
contextLogger.debug(
{
assignmentStrategy: SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID ? "default_team" : "first_organization",
},
"Determining organization assignment"
);
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID);
} else {
@@ -298,29 +170,13 @@ export const handleSsoCallback = async ({
}
if (!organization) {
contextLogger.debug(
{ reason: "no_organization_found" },
"SSO callback rejected: no organization found for assignment"
);
return false;
}
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement && !callbackUrl) {
contextLogger.debug(
{
reason: "insufficient_role_permissions",
organizationId: organization.id,
canDoRoleManagement,
},
"SSO callback rejected: insufficient role management permissions"
);
return false;
}
if (!canDoRoleManagement && !callbackUrl) return false;
}
contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user");
const userProfile = await createUser({
name:
userName ||
@@ -335,28 +191,13 @@ export const handleSsoCallback = async ({
locale: await findMatchingLocale(),
});
contextLogger.debug(
{ newUserId: userProfile.id, identityProvider: provider },
"New SSO user created successfully"
);
// send new user to brevo
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
if (isMultiOrgEnabled) {
contextLogger.debug(
{ isMultiOrgEnabled, newUserId: userProfile.id },
"Multi-org enabled, skipping organization assignment"
);
return true;
}
if (isMultiOrgEnabled) return true;
// Default organization assignment if env variable is set
if (organization) {
contextLogger.debug(
{ newUserId: userProfile.id, organizationId: organization.id, role: "member" },
"Assigning user to organization"
);
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
await createAccount({
...account,
@@ -364,10 +205,6 @@ export const handleSsoCallback = async ({
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
contextLogger.debug(
{ newUserId: userProfile.id, defaultTeamId: DEFAULT_TEAM_ID },
"Creating default team membership"
);
await createDefaultTeamMembership(userProfile.id);
}
@@ -389,7 +226,6 @@ export const handleSsoCallback = async ({
// Without default organization assignment
return true;
}
contextLogger.debug("SSO callback successful: default return");
return true;
};

View File

@@ -85,13 +85,6 @@ vi.mock("@formbricks/lib/jwt", () => ({
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
debug: vi.fn(),
withContext: (context: Record<string, any>) => {
return {
...context,
debug: vi.fn(),
};
},
},
}));

View File

@@ -2,7 +2,6 @@
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { useTranslate } from "@tolgee/react";
@@ -22,7 +21,6 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
{t("environments.project.teams.team_name")}
</TableHead>
<TableHead className="font-medium text-slate-500">{t("common.size")}</TableHead>
<TableHead className="font-medium text-slate-500">{t("common.team_id")}</TableHead>
<TableHead className="font-medium text-slate-500">
{t("environments.project.teams.permission")}
</TableHead>
@@ -42,9 +40,6 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
<TableCell>
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
</TableCell>
<TableCell>
<IdBadge id={team.id} showCopyIconOnHover={true} />
</TableCell>
<TableCell>
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>
</TableCell>

View File

@@ -26,7 +26,6 @@ import {
DialogTitle,
} from "@/modules/ui/components/dialog";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Input } from "@/modules/ui/components/input";
import {
Select,
@@ -236,8 +235,6 @@ export const TeamSettingsModal = ({
)}
/>
<IdBadge id={team.id} label={t("common.team_id")} variant="column" />
{/* Members Section */}
<div className="space-y-2">
<div className="flex flex-col space-y-1">

View File

@@ -313,32 +313,32 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Section className="mx-0 mt-4">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Section className="mx-0 mt-4">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Cal:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>

View File

@@ -2,10 +2,10 @@ import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { EnvironmentIdField } from "@/modules/projects/settings/(setup)/components/environment-id-field";
import { SetupInstructions } from "@/modules/projects/settings/(setup)/components/setup-instructions";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { EnvironmentNotice } from "@/modules/ui/components/environment-notice";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
@@ -38,7 +38,7 @@ export const AppConnectionPage = async (props) => {
<SettingsCard
title={t("environments.project.app-connection.environment_id")}
description={t("environments.project.app-connection.environment_id_description")}>
<IdBadge id={params.environmentId} />
<EnvironmentIdField environmentId={params.environmentId} />
</SettingsCard>
</div>
</PageContentWrapper>

View File

@@ -0,0 +1,38 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EnvironmentIdField } from "./environment-id-field";
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: ({ children, language }: any) => (
<pre data-testid="code-block" data-language={language}>
{children}
</pre>
),
}));
describe("EnvironmentIdField", () => {
afterEach(() => {
cleanup();
});
test("renders the environment id in a code block", () => {
const envId = "env-123";
render(<EnvironmentIdField environmentId={envId} />);
const codeBlock = screen.getByTestId("code-block");
expect(codeBlock).toBeInTheDocument();
expect(codeBlock).toHaveAttribute("data-language", "js");
expect(codeBlock).toHaveTextContent(envId);
});
test("applies the correct wrapper class", () => {
render(<EnvironmentIdField environmentId="env-abc" />);
const wrapper = codeBlockParent();
expect(wrapper).toHaveClass("prose");
expect(wrapper).toHaveClass("prose-slate");
expect(wrapper).toHaveClass("-mt-3");
});
});
function codeBlockParent() {
return screen.getByTestId("code-block").parentElement as HTMLElement;
}

View File

@@ -0,0 +1,11 @@
"use client";
import { CodeBlock } from "@/modules/ui/components/code-block";
export const EnvironmentIdField = ({ environmentId }: { environmentId: string }) => {
return (
<div className="prose prose-slate -mt-3">
<CodeBlock language="js">{environmentId}</CodeBlock>
</div>
);
};

View File

@@ -19,11 +19,10 @@ vi.mock("@/modules/ui/components/page-header", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id, label, variant }: any) => (
<div data-testid="id-badge" data-variant={variant}>
<span data-testid="id-badge-label">{label}</span>
<span data-testid="id-badge-id">{id}</span>
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: any) => (
<div data-testid="settings-id">
<p>{title}</p>:<p>{id}</p>
</div>
),
}));
@@ -105,7 +104,7 @@ describe("GeneralSettingsPage", () => {
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("project-config-navigation")).toBeInTheDocument();
expect(screen.getAllByTestId("id-badge").length).toBe(2);
expect(screen.getAllByTestId("settings-id").length).toBe(2);
expect(screen.getByTestId("edit-project-name-form")).toBeInTheDocument();
expect(screen.getByTestId("edit-waiting-time-form")).toBeInTheDocument();
expect(screen.getByTestId("delete-project")).toBeInTheDocument();

View File

@@ -3,9 +3,9 @@ import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getProjects } from "@/lib/project/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import packageJson from "@/package.json";
import { getTranslate } from "@/tolgee/server";
import { DeleteProject } from "./components/delete-project";
@@ -49,10 +49,10 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
isOwnerOrManager={isOwnerOrManager}
/>
</SettingsCard>
<div className="space-y-2">
<IdBadge id={project.id} label={t("common.project_id")} variant="column" />
<div>
<SettingsId title={t("common.project_id")} id={project.id}></SettingsId>
{!IS_FORMBRICKS_CLOUD && !IS_DEVELOPMENT && (
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
<SettingsId title={t("common.formbricks_version")} id={packageJson.version}></SettingsId>
)}
</div>
</PageContentWrapper>

View File

@@ -35,7 +35,7 @@ export const ClientLogo = ({ environmentId, projectLogo, previewSurvey = false }
src={projectLogo?.url}
className={cn(
previewSurvey ? "max-h-12" : "max-h-16 md:max-h-20",
"w-auto max-w-40 object-contain p-1 md:max-w-56"
"w-auto max-w-40 rounded-lg object-contain p-1 md:max-w-56"
)}
width={256}
height={64}

View File

@@ -22,16 +22,6 @@ export const DataTableSettingsModalItem = <T,>({ column, survey }: DataTableSett
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: column.id,
});
const isOptionIdColumn = column.id.endsWith("optionIds");
const getOptionIdColumnLabel = () => {
const questionId = column.id.split("optionIds")[0];
const question = survey?.questions.find((q) => q.id === questionId);
if (question) {
return `${getLocalizedValue(question.headline, "default")} - ${t("common.option_id")}`;
}
return null;
};
const getLabelFromColumnId = () => {
switch (column.id) {
@@ -87,9 +77,7 @@ export const DataTableSettingsModalItem = <T,>({ column, survey }: DataTableSett
<span className="max-w-xs truncate">{getLocalizedValue(question.headline, "default")}</span>
</div>
) : (
<span className="max-w-xs truncate">
{isOptionIdColumn ? getOptionIdColumnLabel() : getLabelFromColumnId()}
</span>
<span className="max-w-xs truncate">{getLabelFromColumnId()}</span>
)}
</div>
<Switch

View File

@@ -24,16 +24,6 @@ vi.mock("@tolgee/react", () => ({
}),
}));
// Mock lucide-react icons used in IdBadge
vi.mock("lucide-react", async () => {
const actual = await vi.importActual<typeof import("lucide-react")>("lucide-react");
return {
...actual,
Copy: () => "Copy Icon",
Check: () => "Check Icon",
};
});
describe("IdBadge", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -0,0 +1,35 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { SettingsId } from "./index";
describe("SettingsId", () => {
afterEach(() => {
cleanup();
});
test("renders the title and id correctly", () => {
render(<SettingsId title="Survey ID" id="survey-123" />);
const element = screen.getByText(/Survey ID: survey-123/);
expect(element).toBeInTheDocument();
expect(element.tagName.toLowerCase()).toBe("p");
});
test("applies correct styling", () => {
render(<SettingsId title="Environment ID" id="env-456" />);
const element = screen.getByText(/Environment ID: env-456/);
expect(element).toHaveClass("py-1");
expect(element).toHaveClass("text-xs");
expect(element).toHaveClass("text-slate-400");
});
test("renders with very long id", () => {
const longId = "a".repeat(100);
render(<SettingsId title="API Key" id={longId} />);
const element = screen.getByText(`API Key: ${longId}`);
expect(element).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,12 @@
interface SettingsIdProps {
title: string;
id: string;
}
export const SettingsId = ({ title, id }: SettingsIdProps) => {
return (
<p className="py-1 text-xs text-slate-400">
{title}: {id}
</p>
);
};

View File

@@ -531,3 +531,13 @@ module "formbricks_app_iam_role" {
}
}
}
module "account_public_access" {
source = "terraform-aws-modules/s3-bucket/aws//modules/account-public-access"
version = "4.6.0"
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}

View File

@@ -1,10 +0,0 @@
module.exports = {
extends: ["@formbricks/eslint-config/library.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
rules: {
"no-console": "off",
},
};

View File

@@ -1,198 +0,0 @@
# @formbricks/ai
A model-agnostic AI package for Formbricks, providing a unified interface for LLM operations across different providers.
## Features
- **Multi-Provider Support**: OpenAI and Anthropic models with easy switching
- **Type-Safe**: Full TypeScript support with schema validation
- **Environment-Based Configuration**: Automatic provider selection via environment variables
- **Structured Output**: Generate validated JSON objects from prompts using schemas
- **Helper Functions**: Built-in summarization and translation utilities
## Installation
This package is part of the Formbricks monorepo and is intended for internal use.
```bash
pnpm install @formbricks/ai
```
## Quick Start
### Environment Configuration
Set up your environment variables:
```bash
# Provider selection (defaults to "openai")
AI_PROVIDER=openai # or "anthropic"
# Model selection (uses sensible defaults if not specified)
AI_MODEL=gpt-4 # or "claude-3-sonnet-20240229"
# API Keys
OPENAI_API_KEY=your_openai_key
ANTHROPIC_API_KEY=your_anthropic_key
# Optional: Custom base URL
AI_BASE_URL=https://your-custom-endpoint.com
```
### Basic Usage
#### Text Generation
```typescript
import { generateText } from "@formbricks/ai";
const result = await generateText({
prompt: "Explain quantum computing in simple terms",
system: "You are a helpful science teacher",
temperature: 0.7,
maxTokens: 200,
});
console.log(result.text);
```
#### Structured Object Generation
```typescript
import { z } from "zod";
import { generateObject } from "@formbricks/ai";
const analysisSchema = z.object({
sentiment: z.enum(["positive", "negative", "neutral"]),
summary: z.string(),
keyTopics: z.array(z.string()),
confidence: z.number().min(0).max(1),
});
const result = await generateObject({
prompt: "Analyze this customer feedback: 'The product is amazing but delivery was slow'",
schema: analysisSchema,
temperature: 0.3,
});
console.log(result.object.sentiment); // Type-safe access
console.log(result.object.keyTopics);
```
#### Helper Functions
```typescript
import { summarizeText, translateText } from "@formbricks/ai";
// Summarization
const summary = await summarizeText(longText, 150);
// Translation
const translated = await translateText("Hello, how are you?", "Spanish", "English");
```
## Configuration
### Programmatic Configuration
You can override environment configuration programmatically:
```typescript
import { createAIModel, generateText } from "@formbricks/ai";
const customConfig = {
provider: "anthropic" as const,
model: "claude-3-haiku-20240307",
apiKey: "your-api-key",
};
// Use custom config for specific calls
const result = await generateText(
{
prompt: "Hello world",
},
customConfig
);
// Or create a reusable model instance
const aiModel = createAIModel(customConfig);
```
### Supported Models
#### OpenAI
- `gpt-4` (default)
- `gpt-4-turbo`
- `gpt-3.5-turbo`
#### Anthropic
- `claude-3-sonnet-20240229` (default)
- `claude-3-haiku-20240307`
- `claude-3-opus-20240229`
## Error Handling
The package provides clear error messages for common issues:
```typescript
import { generateText, isAIConfigured } from "@formbricks/ai";
// Check if AI is properly configured
if (!isAIConfigured()) {
throw new Error("AI is not properly configured. Please check your environment variables.");
}
try {
const result = await generateText({
prompt: "Your prompt here",
});
} catch (error) {
console.error("AI generation failed:", error.message);
}
```
## Usage in Formbricks
This package is designed to be used across the Formbricks ecosystem:
- **NextJS API Routes**: For server-side AI operations
- **Background Jobs**: For processing surveys and responses
- **Future NestJS Backend**: Modular design allows easy integration
## Development
### Building
```bash
pnpm build
```
### Development Mode
```bash
pnpm dev
```
### Code Quality
```bash
pnpm lint
```
## Architecture
The package follows a layered architecture:
1. **Types Layer** (`types.ts`): TypeScript definitions and interfaces
2. **Configuration Layer** (`config.ts`): Provider setup and validation
3. **Abstraction Layer** (`ai.ts`): Main API functions
4. **Export Layer** (`index.ts`): Public API exports
This design ensures:
- Easy testing and mocking
- Provider-agnostic implementation
- Type safety throughout
- Consistent error handling

View File

@@ -1,39 +0,0 @@
{
"name": "@formbricks/ai",
"packageManager": "pnpm@9.15.9",
"private": true,
"version": "0.1.0",
"main": "./dist/index.cjs",
"types": "./dist/index.d.ts",
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"clean": "rimraf .turbo node_modules dist",
"build": "vite build",
"dev": "vite build --watch",
"lint": "eslint ./src --fix",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/anthropic": "^1.0.6",
"@ai-sdk/openai": "^1.0.20",
"ai": "^5.0.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"typescript": "5.8.3",
"vite": "6.3.5",
"vite-plugin-dts": "4.5.3"
}
}

View File

@@ -1,196 +0,0 @@
import { generateObject as aiGenerateObject, generateText as aiGenerateText } from "ai";
import type { z } from "zod";
import { createAIModel } from "./config";
import type {
GenerateObjectOptions,
GenerateObjectResult,
GenerateTextOptions,
GenerateTextResult,
ProviderConfig,
} from "./types";
/**
* Singleton AI model instance for reuse across calls
*/
let aiModelInstance: ReturnType<typeof createAIModel> | null = null;
/**
* Get or create the AI model instance
*/
function getAIModel(customConfig?: ProviderConfig) {
if (!aiModelInstance || customConfig) {
aiModelInstance = createAIModel(customConfig);
}
return aiModelInstance;
}
/**
* Generate text using the configured AI model
*
* @param options - Text generation options
* @returns Promise resolving to generated text and usage information
*
* @example
* ```typescript
* const result = await generateText({
* prompt: "Summarize the following text: Lorem ipsum...",
* system: "You are a helpful assistant that provides concise summaries.",
* temperature: 0.7,
* maxTokens: 150
* });
*
* console.log(result.text);
* ```
*/
export async function generateText(
options: GenerateTextOptions,
customConfig?: ProviderConfig
): Promise<GenerateTextResult> {
const { model } = getAIModel(customConfig);
try {
const result = await aiGenerateText({
model,
prompt: options.prompt,
system: options.system,
temperature: options.temperature,
...(options.maxTokens && { maxTokens: options.maxTokens }),
});
return {
text: result.text,
usage: result.usage
? {
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
totalTokens: result.usage.totalTokens,
}
: undefined,
};
} catch (error) {
throw new Error(`Failed to generate text: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Generate a structured object using the configured AI model
*
* @param options - Object generation options including Zod schema
* @returns Promise resolving to generated object and usage information
*
* @example
* ```typescript
* import { z } from "zod";
*
* const summarySchema = z.object({
* title: z.string(),
* summary: z.string(),
* keyPoints: z.array(z.string()),
* sentiment: z.enum(['positive', 'negative', 'neutral'])
* });
*
* const result = await generateObject({
* prompt: "Analyze the following article: Lorem ipsum...",
* schema: summarySchema,
* system: "You are an expert content analyzer.",
* temperature: 0.3
* });
*
* console.log(result.object.title);
* console.log(result.object.keyPoints);
* ```
*/
export async function generateObject<T extends z.ZodSchema>(
options: GenerateObjectOptions<T>,
customConfig?: ProviderConfig
): Promise<GenerateObjectResult<z.infer<T>>> {
const { model } = getAIModel(customConfig);
try {
const result = await aiGenerateObject({
model,
prompt: options.prompt,
schema: options.schema,
system: options.system,
temperature: options.temperature,
...(options.maxTokens && { maxTokens: options.maxTokens }),
});
return {
object: result.object,
usage: result.usage
? {
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
totalTokens: result.usage.totalTokens,
}
: undefined,
};
} catch (error) {
throw new Error(`Failed to generate object: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Helper function for text summarization
*
* @param text - Text to summarize
* @param maxLength - Optional maximum length for the summary
* @returns Promise resolving to the summary
*/
export async function summarizeText(
text: string,
maxLength?: number,
customConfig?: ProviderConfig
): Promise<string> {
const prompt = `Summarize the following text${maxLength ? ` in approximately ${maxLength} characters` : ""}:\n\n${text}`;
const result = await generateText(
{
prompt,
system:
"You are a helpful assistant that creates clear, concise summaries. Focus on the key points and main ideas.",
temperature: 0.3,
maxTokens: maxLength ? Math.ceil(maxLength / 3) : undefined, // Rough token estimate
},
customConfig
);
return result.text;
}
/**
* Helper function for text translation
*
* @param text - Text to translate
* @param targetLanguage - Target language for translation
* @param sourceLanguage - Optional source language (auto-detected if not provided)
* @returns Promise resolving to the translated text
*/
export async function translateText(
text: string,
targetLanguage: string,
sourceLanguage?: string,
customConfig?: ProviderConfig
): Promise<string> {
const sourceText = sourceLanguage ? `from ${sourceLanguage} ` : "";
const prompt = `Translate the following text ${sourceText}to ${targetLanguage}:\n\n${text}`;
const result = await generateText(
{
prompt,
system: `You are a professional translator. Provide only the translated text without any additional commentary or explanations. Maintain the original tone and style.`,
temperature: 0.1, // Low temperature for consistency
},
customConfig
);
return result.text;
}
/**
* Reset the AI model instance (useful for testing or when configuration changes)
*/
export function resetAIModel(): void {
aiModelInstance = null;
}

View File

@@ -1,168 +0,0 @@
import { anthropic } from "@ai-sdk/anthropic";
import { openai } from "@ai-sdk/openai";
import type { LanguageModelV1 } from "ai";
import type {
AIEnvironmentConfig,
AIModelInstance,
AIProvider,
AnthropicConfig,
OpenAIConfig,
ProviderConfig,
} from "./types";
/**
* Default models for each provider
*/
const DEFAULT_MODELS: Record<AIProvider, string> = {
openai: "gpt-4",
anthropic: "claude-3-sonnet-20240229",
};
/**
* Get environment configuration from process.env
*/
function getEnvironmentConfig(): AIEnvironmentConfig {
return {
AI_PROVIDER: process.env.AI_PROVIDER,
AI_MODEL: process.env.AI_MODEL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
AI_BASE_URL: process.env.AI_BASE_URL,
};
}
/**
* Create provider configuration from environment variables
*/
function createProviderConfigFromEnv(): ProviderConfig {
const env = getEnvironmentConfig();
// Determine provider (default to openai if not specified)
const provider = (env.AI_PROVIDER as AIProvider) || "openai";
// Get model for the provider
const model = env.AI_MODEL || DEFAULT_MODELS[provider];
// Create configuration based on provider
switch (provider) {
case "openai": {
const config: OpenAIConfig = {
provider: "openai",
model,
apiKey: env.OPENAI_API_KEY,
baseURL: env.AI_BASE_URL,
};
return config;
}
case "anthropic": {
const config: AnthropicConfig = {
provider: "anthropic",
model,
apiKey: env.ANTHROPIC_API_KEY,
baseURL: env.AI_BASE_URL,
};
return config;
}
default:
throw new Error(`Unsupported AI provider: ${provider}`);
}
}
/**
* Create a language model instance from provider configuration
*/
function createModelFromConfig(config: ProviderConfig): LanguageModelV1 {
switch (config.provider) {
case "openai": {
const options: any = {};
if (config.apiKey) {
options.apiKey = config.apiKey;
}
if (config.baseURL) {
options.baseURL = config.baseURL;
}
return openai(config.model, options);
}
case "anthropic": {
const options: any = {};
if (config.apiKey) {
options.apiKey = config.apiKey;
}
if (config.baseURL) {
options.baseURL = config.baseURL;
}
return anthropic(config.model, options);
}
default:
throw new Error(`Unsupported provider: ${(config as ProviderConfig).provider}`);
}
}
/**
* Validate that required API keys are present for the configured provider
*/
function validateConfiguration(config: ProviderConfig): void {
switch (config.provider) {
case "openai":
if (!config.apiKey && !process.env.OPENAI_API_KEY) {
throw new Error(
"OpenAI API key is required. Set OPENAI_API_KEY environment variable or provide apiKey in configuration."
);
}
break;
case "anthropic":
if (!config.apiKey && !process.env.ANTHROPIC_API_KEY) {
throw new Error(
"Anthropic API key is required. Set ANTHROPIC_API_KEY environment variable or provide apiKey in configuration."
);
}
break;
default:
throw new Error(`Unsupported provider: ${(config as ProviderConfig).provider}`);
}
}
/**
* Create and configure the AI model instance
*/
export function createAIModel(customConfig?: ProviderConfig): AIModelInstance {
// Use custom config or create from environment
const config = customConfig || createProviderConfigFromEnv();
// Validate the configuration
validateConfiguration(config);
// Create the model instance
const model = createModelFromConfig(config);
return {
model,
config,
};
}
/**
* Get the current provider configuration without creating a model
*/
export function getProviderConfig(): ProviderConfig {
return createProviderConfigFromEnv();
}
/**
* Check if AI is properly configured
*/
export function isAIConfigured(): boolean {
try {
const config = createProviderConfigFromEnv();
validateConfiguration(config);
return true;
} catch {
return false;
}
}

View File

@@ -1,20 +0,0 @@
// Main AI functions
export { generateText, generateObject, summarizeText, translateText, resetAIModel } from "./ai";
// Configuration functions
export { createAIModel, getProviderConfig, isAIConfigured } from "./config";
// Types
export type {
AIProvider,
AIProviderConfig,
OpenAIConfig,
AnthropicConfig,
ProviderConfig,
AIEnvironmentConfig,
GenerateTextOptions,
GenerateObjectOptions,
GenerateTextResult,
GenerateObjectResult,
AIModelInstance,
} from "./types";

View File

@@ -1,102 +0,0 @@
import type { LanguageModelV1 } from "ai";
import type { z } from "zod";
/**
* Supported AI providers
*/
export type AIProvider = "openai" | "anthropic";
/**
* Configuration for different AI providers
*/
export interface AIProviderConfig {
provider: AIProvider;
model: string;
apiKey?: string;
baseURL?: string;
}
/**
* OpenAI specific configuration
*/
export interface OpenAIConfig extends AIProviderConfig {
provider: "openai";
model: string; // e.g., "gpt-4", "gpt-3.5-turbo"
}
/**
* Anthropic specific configuration
*/
export interface AnthropicConfig extends AIProviderConfig {
provider: "anthropic";
model: string; // e.g., "claude-3-sonnet-20240229", "claude-3-haiku-20240307"
}
/**
* Union type for all provider configurations
*/
export type ProviderConfig = OpenAIConfig | AnthropicConfig;
/**
* Environment variables for AI configuration
*/
export interface AIEnvironmentConfig {
AI_PROVIDER?: string;
AI_MODEL?: string;
OPENAI_API_KEY?: string;
ANTHROPIC_API_KEY?: string;
AI_BASE_URL?: string;
}
/**
* Options for text generation
*/
export interface GenerateTextOptions {
prompt: string;
system?: string;
temperature?: number;
maxTokens?: number;
}
/**
* Options for object generation
*/
export interface GenerateObjectOptions<T extends z.ZodSchema> {
prompt: string;
schema: T;
system?: string;
temperature?: number;
maxTokens?: number;
}
/**
* Result from text generation
*/
export interface GenerateTextResult {
text: string;
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
}
/**
* Result from object generation
*/
export interface GenerateObjectResult<T> {
object: T;
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
};
}
/**
* Internal type for the language model instance
*/
export interface AIModelInstance {
model: LanguageModelV1;
config: ProviderConfig;
}

View File

@@ -1,12 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
"extends": "@formbricks/config-typescript/js-library.json",
"include": ["src/**/*"]
}

View File

@@ -1,49 +0,0 @@
import { resolve } from "path";
import { UserConfig, defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig((): UserConfig => {
return {
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
build: {
rollupOptions: {
input: {
index: resolve(__dirname, "src/index.ts"),
},
output: [
{
format: "esm",
entryFileNames: "[name].js",
chunkFileNames: "[name].js",
},
{
format: "cjs",
entryFileNames: "[name].cjs",
chunkFileNames: "[name].cjs",
},
],
external: [
// External dependencies that should not be bundled
"@ai-sdk/anthropic",
"@ai-sdk/openai",
"ai",
"zod",
],
},
emptyOutDir: true,
ssr: true, // Server-side rendering mode for Node.js
},
plugins: [
dts({
rollupTypes: false,
include: ["src/**/*"],
exclude: ["src/**/*.test.ts", "src/**/*.spec.ts"],
insertTypesEntry: true,
}),
],
};
});

View File

@@ -136,7 +136,11 @@ export function WelcomeCard({
<ScrollableContainer>
<div>
{fileUrl ? (
<img src={fileUrl} className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-object-contain" alt="Company Logo" />
<img
src={fileUrl}
className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-rounded-lg fb-object-contain"
alt="Company Logo"
/>
) : null}
<Headline

149
pnpm-lock.yaml generated
View File

@@ -536,37 +536,6 @@ importers:
specifier: 3.1.0
version: 3.1.0(typescript@5.8.3)(vitest@3.1.3(@types/node@22.15.18)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0))
packages/ai:
dependencies:
'@ai-sdk/anthropic':
specifier: ^1.0.6
version: 1.2.12(zod@3.24.4)
'@ai-sdk/openai':
specifier: ^1.0.20
version: 1.3.23(zod@3.24.4)
ai:
specifier: ^5.0.2
version: 5.0.2(zod@3.24.4)
zod:
specifier: 3.24.4
version: 3.24.4
devDependencies:
'@formbricks/config-typescript':
specifier: workspace:*
version: link:../config-typescript
'@formbricks/eslint-config':
specifier: workspace:*
version: link:../config-eslint
typescript:
specifier: 5.8.3
version: 5.8.3
vite:
specifier: 6.3.5
version: 6.3.5(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0)
vite-plugin-dts:
specifier: 4.5.3
version: 4.5.3(@types/node@22.15.18)(rollup@4.46.1)(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0))
packages/config-eslint:
devDependencies:
'@next/eslint-plugin-next':
@@ -855,44 +824,6 @@ packages:
'@adobe/css-tools@4.4.3':
resolution: {integrity: sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==}
'@ai-sdk/anthropic@1.2.12':
resolution: {integrity: sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/gateway@1.0.0':
resolution: {integrity: sha512-VEm87DyRx1yIPywbTy8ntoyh4jEDv1rJ88m+2I7zOm08jJI5BhFtAWh0OF6YzZu1Vu4NxhOWO4ssGdsqydDQ3A==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/openai@1.3.23':
resolution: {integrity: sha512-86U7rFp8yacUAOE/Jz8WbGcwMCqWvjK33wk5DXkfnAOEn3mx2r7tNSJdjukQFZbAK97VMXGPPHxF+aEARDXRXQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/provider-utils@2.2.8':
resolution: {integrity: sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.23.8
'@ai-sdk/provider-utils@3.0.0':
resolution: {integrity: sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
'@ai-sdk/provider@1.1.3':
resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==}
engines: {node: '>=18'}
'@ai-sdk/provider@2.0.0':
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==}
engines: {node: '>=18'}
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -3803,9 +3734,6 @@ packages:
'@sqltools/formatter@1.2.5':
resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==}
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
@@ -4749,12 +4677,6 @@ packages:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'}
ai@5.0.2:
resolution: {integrity: sha512-Uk4lmwlr2b/4G9DUYCWYKcWz93xQ6p6AEeRZN+/AO9NbOyCm9axrDru26c83Ax8OB8IHUvoseA3CqaZkg9Z0Kg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4
ajv-draft-04@1.0.0:
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
peerDependencies:
@@ -5965,10 +5887,6 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
eventsource-parser@3.0.3:
resolution: {integrity: sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==}
engines: {node: '>=20.0.0'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
@@ -6805,9 +6723,6 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -9575,11 +9490,6 @@ packages:
peerDependencies:
zod: ^3.21.4
zod-to-json-schema@3.24.6:
resolution: {integrity: sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==}
peerDependencies:
zod: ^3.24.1
zod@3.24.4:
resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==}
@@ -9587,47 +9497,6 @@ snapshots:
'@adobe/css-tools@4.4.3': {}
'@ai-sdk/anthropic@1.2.12(zod@3.24.4)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.24.4)
zod: 3.24.4
'@ai-sdk/gateway@1.0.0(zod@3.24.4)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.0(zod@3.24.4)
zod: 3.24.4
'@ai-sdk/openai@1.3.23(zod@3.24.4)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.24.4)
zod: 3.24.4
'@ai-sdk/provider-utils@2.2.8(zod@3.24.4)':
dependencies:
'@ai-sdk/provider': 1.1.3
nanoid: 3.3.11
secure-json-parse: 2.7.0
zod: 3.24.4
'@ai-sdk/provider-utils@3.0.0(zod@3.24.4)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@standard-schema/spec': 1.0.0
eventsource-parser: 3.0.3
zod: 3.24.4
zod-to-json-schema: 3.24.6(zod@3.24.4)
'@ai-sdk/provider@1.1.3':
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@2.0.0':
dependencies:
json-schema: 0.4.0
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.3.0':
@@ -13637,8 +13506,6 @@ snapshots:
'@sqltools/formatter@1.2.5': {}
'@standard-schema/spec@1.0.0': {}
'@standard-schema/utils@0.3.0': {}
'@storybook/addon-a11y@9.0.15(storybook@9.0.15(@testing-library/dom@8.20.1)(prettier@3.5.3))':
@@ -14757,14 +14624,6 @@ snapshots:
indent-string: 4.0.0
optional: true
ai@5.0.2(zod@3.24.4):
dependencies:
'@ai-sdk/gateway': 1.0.0(zod@3.24.4)
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.0(zod@3.24.4)
'@opentelemetry/api': 1.9.0
zod: 3.24.4
ajv-draft-04@1.0.0(ajv@8.13.0):
optionalDependencies:
ajv: 8.13.0
@@ -16184,8 +16043,6 @@ snapshots:
events@3.3.0: {}
eventsource-parser@3.0.3: {}
expand-template@2.0.3: {}
expect-type@1.2.2: {}
@@ -17069,8 +16926,6 @@ snapshots:
json-schema-traverse@1.0.0: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@1.0.2:
@@ -20024,8 +19879,4 @@ snapshots:
dependencies:
zod: 3.24.4
zod-to-json-schema@3.24.6(zod@3.24.4):
dependencies:
zod: 3.24.4
zod@3.24.4: {}