Compare commits

..

21 Commits

Author SHA1 Message Date
Matti Nannt
ad842e0e80 chore: fix openAPI specs throw error (#4662) 2025-01-24 10:55:34 +01:00
Dhruwang Jariwala
dcf4109c5b docs: rate limiting docs (#4652)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-01-24 06:25:42 +00:00
Chromico Rek
05287c135e fix: prevent duplicate value appending in docker-compose.yml (#4618) 2025-01-24 05:52:32 +00:00
Piyush Gupta
6ff8ec21cf fix: e2e test (#4660) 2025-01-24 04:57:23 +00:00
Matti Nannt
7b6e22aa04 chore: increase version number to 3.1.2 (#4658) 2025-01-23 19:00:04 +01:00
Piyush Gupta
ee56914285 fix: onboarding organization creation bug (#4641) 2025-01-23 16:36:45 +00:00
Paribesh Nepal
a2e9cd3c43 fix: Increase response limit (#4650)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
2025-01-23 12:23:48 +00:00
Dhruwang Jariwala
359f29a264 chore: added playwright cloud to gh workflow (#4556)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2025-01-23 09:22:56 +00:00
Dhruwang Jariwala
576b15fec0 feat: cmd + enter to jump to next question (#4626) 2025-01-23 06:55:01 +00:00
Dhruwang Jariwala
42434290da fix: created at mapping in notion integration (#4640) 2025-01-23 06:01:41 +00:00
dependabot[bot]
62c6189dfd chore(deps-dev): bump the npm_and_yarn group across 9 directories with 1 update (#4639)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-22 21:09:12 +00:00
Matti Nannt
21c9ebbca3 chore: increase version number to 3.1.1 (#4647) 2025-01-22 17:48:34 +01:00
Matti Nannt
658d4687f9 fix: draft release workflow (#4643)
Co-authored-by: GitHub Actions <github-actions@github.com>
2025-01-22 15:38:02 +01:00
Dhruwang Jariwala
3775453db8 chore: permissions to workflows (#4599)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-01-22 12:06:52 +00:00
Dhruwang Jariwala
edcaf8e639 fix: recall to ending card button url (#4627) 2025-01-22 12:05:50 +00:00
Matti Nannt
3aa658a64e chore: add release workflow (#4638) 2025-01-22 12:08:33 +01:00
Paribesh Nepal
58fc66ad1c fix: Change type Single / Multi select to Ranking should not remove o… (#4622) 2025-01-21 05:20:37 +00:00
Dhruwang Jariwala
f68f87645f fix: column ordering (#4621) 2025-01-21 05:06:43 +00:00
Paribesh Nepal
25f99da172 fix: colour picker (#4619) 2025-01-20 04:17:11 +00:00
Anshuman Pandey
5da6faa972 fix: overlapping UI and translations (#4615) 2025-01-17 13:58:15 +00:00
Piyush Gupta
02b25138ef chore: API key types (#4610)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-01-17 10:36:29 +00:00
68 changed files with 1574 additions and 558 deletions

View File

@@ -1,6 +1,10 @@
name: Build Docs
on:
workflow_call:
permissions:
contents: read
jobs:
build:
name: Build Docs

View File

@@ -1,6 +1,10 @@
name: Build Web
on:
workflow_call:
permissions:
contents: read
jobs:
build:
name: Build Formbricks-web

View File

@@ -7,6 +7,10 @@ on:
schedule:
# Runs "At 00:00." (see https://crontab.guru)
- cron: "0 0 * * *"
permissions:
contents: read
jobs:
cron-weeklySummary:
env:

View File

@@ -9,6 +9,8 @@ on:
- cron: "0 8 * * 1"
jobs:
cron-weeklySummary:
permissions:
contents: read
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}

55
.github/workflows/draft-release.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Draft release
run-name: Draft release ${{ inputs.next_version }}
on:
workflow_dispatch:
inputs:
next_version:
required: true
type: string
description: "Version name"
permissions:
contents: write
jobs:
draft_release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: ./.github/actions/dangerous-git-checkout
- name: Configure git
run: |
git config --local user.email "github-actions@github.com"
git config --local user.name "GitHub Actions"
- name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Bump version
run: |
cd apps/web
pnpm version ${{ inputs.next_version }} --no-workspaces-update
- name: Commit changes
run: |
git add .
git commit -m "chore: release v${{ inputs.next_version }}"
git push
- name: Draft release
run: gh release create v${{ inputs.next_version }} --generate-notes --draft
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.next_version }}

View File

@@ -1,9 +1,28 @@
name: E2E Tests
on:
workflow_call:
secrets:
AZURE_CLIENT_ID:
required: false
AZURE_TENANT_ID:
required: false
AZURE_SUBSCRIPTION_ID:
required: false
PLAYWRIGHT_SERVICE_URL:
required: false
# Add other secrets if necessary
workflow_dispatch:
env:
TELEMETRY_DISABLED: 1
permissions:
id-token: write
contents: read
actions: read
checks: write
jobs:
build:
name: Run E2E Tests
@@ -83,7 +102,31 @@ jobs:
- name: Install Playwright
run: pnpm exec playwright install --with-deps
- name: Run E2E Tests
- name: Set Azure Secret Variables
run: |
if [[ -n "${{ secrets.AZURE_CLIENT_ID }}" && -n "${{ secrets.AZURE_TENANT_ID }}" && -n "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]]; then
echo "AZURE_ENABLED=true" >> $GITHUB_ENV
else
echo "AZURE_ENABLED=false" >> $GITHUB_ENV
fi
- name: Azure login
if: env.AZURE_ENABLED == 'true'
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Run E2E Tests (Azure)
if: env.AZURE_ENABLED == 'true'
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
run: |
pnpm test-e2e:azure
- name: Run E2E Tests (Local)
if: env.AZURE_ENABLED == 'false'
run: |
pnpm test:e2e

View File

@@ -1,6 +1,10 @@
name: Lint
on:
workflow_call:
permissions:
contents: read
jobs:
build:
name: Linters
@@ -8,16 +12,16 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 20.x
- name: Install pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64

View File

@@ -1,5 +1,13 @@
name: PR Update
# Update permissions to include all necessary ones
permissions:
contents: read
pull-requests: read
actions: read
checks: write
id-token: write
on:
pull_request:
branches:
@@ -12,55 +20,28 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
permissions:
pull-requests: read
outputs:
has-files-requiring-all-checks: ${{ steps.filter.outputs.has-files-requiring-all-checks }}
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
has-files-requiring-all-checks:
- "!(**.md|.github/CODEOWNERS)"
test:
name: Run Unit Tests
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/test.yml
secrets: inherit
lint:
name: Run Linters
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/lint.yml
secrets: inherit
build:
name: Build Formbricks-web
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/build-web.yml
secrets: inherit
docs:
name: Build Docs
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/build-docs.yml
secrets: inherit
e2e-test:
name: Run E2E Tests
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e.yml
secrets: inherit
@@ -69,6 +50,10 @@ jobs:
needs: [lint, test, build, e2e-test, docs]
if: always()
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
statuses: write
steps:
- name: fail if conditional jobs failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')

View File

@@ -6,6 +6,11 @@ on:
# branches:
# - main
permissions:
contents: write
pull-requests: write
packages: write
concurrency: ${{ github.workflow }}-${{ github.ref }}
env:

View File

@@ -8,6 +8,8 @@ on:
jobs:
release-image-on-dockerhub:
name: Release on Dockerhub
permissions:
contents: read
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}

View File

@@ -6,6 +6,8 @@ jobs:
name: Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@v3

View File

@@ -14,7 +14,6 @@ module.exports = {
typescript: {
project: "tsconfig.json",
},
caseSensitive: false,
},
},
};

View File

@@ -0,0 +1,37 @@
# Rate Limiting
To protect the platform from abuse and ensure fair usage, rate limiting is enforced by default on an IP-address basis. If a client exceeds the allowed number of requests within the specified time window, the API will return a `429 Too Many Requests` status code.
## Default Rate Limits
The following rate limits apply to various endpoints:
| **Endpoint** | **Rate Limit** | **Time Window** |
| ----------------------- | -------------- | --------------- |
| `POST /login` | 30 requests | 15 minutes |
| `POST /signup` | 30 requests | 60 minutes |
| `POST /verify-email` | 10 requests | 60 minutes |
| `POST /forgot-password` | 5 requests | 60 minutes |
| `GET /client-side-api` | 100 requests | 1 minute |
| `POST /share` | 100 requests | 60 minutes |
If a request exceeds the defined rate limit, the server will respond with:
```json
{
"code": 429,
"error": "Too many requests, Please try after a while!"
}
```
## Disabling Rate Limiting
For self-hosters, rate limiting can be disabled if necessary. However, we **strongly recommend keeping rate limiting enabled in production environments** to prevent abuse.
To disable rate limiting, set the following environment variable:
```bash
RATE_LIMITING_DISABLED=1
```
After making this change, restart your server to apply the new setting.

View File

@@ -144,6 +144,7 @@ export const navigation: NavGroup[] = [
{ title: "Integrations", href: "/self-hosting/integrations" },
{ title: "License", href: "/self-hosting/license" },
{ title: "Cluster Setup", href: "/self-hosting/cluster-setup" },
{ title: "Rate Limiting", href: "/self-hosting/rate-limiting" },
],
},
{

View File

@@ -24,6 +24,9 @@ info:
version: 1.0.0
servers:
- url: http://{{baseurl}}
variables:
baseurl:
default: "localhost:3000"
tags:
- name: Client API
description: >-
@@ -151,7 +154,7 @@ tags:
Methods allowed: Get All, Get ,Create, and Delete Webhooks
paths:
/api/v1/client/{environmentId}/displays:
post:

View File

@@ -35,6 +35,6 @@
"prop-types": "15.8.1",
"storybook": "8.4.7",
"tsup": "8.3.5",
"vite": "6.0.3"
"vite": "6.0.9"
}
}

View File

@@ -90,7 +90,14 @@ export const EditorCardMenu = ({
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
card.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle) ||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
card.type === TSurveyQuestionTypeEnum.Ranking) ||
(type === TSurveyQuestionTypeEnum.Ranking &&
card.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
card.type === TSurveyQuestionTypeEnum.Ranking) ||
(type === TSurveyQuestionTypeEnum.Ranking && card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
) {
updateCard(cardIdx, {
choices: card.choices,

View File

@@ -1,12 +1,15 @@
"use client";
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
import { RecallWrapper } from "@/modules/surveys/components/QuestionFormInput/components/RecallWrapper";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useRef } from "react";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { headlineToRecall, recallToHeadline } from "@formbricks/lib/utils/recall";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TSurvey, TSurveyEndScreenCard } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
@@ -35,6 +38,7 @@ export const EndScreenForm = ({
locale,
}: EndScreenFormProps) => {
const t = useTranslations();
const inputRef = useRef<HTMLInputElement>(null);
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
endingCard.type === "endScreen" &&
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
@@ -103,7 +107,7 @@ export const EndScreenForm = ({
id="buttonLabel"
label={t("environments.surveys.edit.button_label")}
placeholder={t("environments.surveys.edit.create_your_own_survey")}
className="bg-white"
className="rounded-md"
value={endingCard.buttonLabel}
localSurvey={localSurvey}
questionIdx={localSurvey.questions.length + endingCardIndex}
@@ -117,14 +121,61 @@ export const EndScreenForm = ({
</div>
<div className="space-y-2">
<Label>{t("environments.surveys.edit.button_url")}</Label>
<Input
id="buttonLink"
name="buttonLink"
className="bg-white"
placeholder="https://formbricks.com"
value={endingCard.buttonLink}
onChange={(e) => updateSurvey({ buttonLink: e.target.value })}
/>
<div className="rounded-md bg-white">
<RecallWrapper
value={endingCard.buttonLink ?? ""}
questionId={endingCard.id}
onChange={(val, recallItems, fallbacks) => {
const updatedValue = {
...endingCard,
buttonLink:
recallItems && fallbacks ? headlineToRecall(val, recallItems, fallbacks) : val,
};
updateSurvey(updatedValue);
}}
onAddFallback={() => {
inputRef.current?.focus();
}}
contactAttributeKeys={contactAttributeKeys}
isRecallAllowed
localSurvey={localSurvey}
usedLanguageCode={"default"}
render={({ value, onChange, highlightedJSX, children }) => {
return (
<div className="group relative">
{/* The highlight container is absolutely positioned behind the input */}
<div
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}
</div>
<Input
ref={inputRef}
id="buttonLink"
name="buttonLink"
className="relative text-black caret-black"
placeholder="https://formbricks.com"
value={
recallToHeadline(
{
[selectedLanguageCode]: value,
},
localSurvey,
false,
"default",
contactAttributeKeys
)[selectedLanguageCode]
}
onChange={(e) => onChange(e.target.value)}
/>
{children}
</div>
);
}}
/>
</div>
</div>
</div>
)}

View File

@@ -166,7 +166,7 @@ export const MatrixQuestionForm = ({
<div
className="flex items-center"
onKeyDown={(e) => handleKeyDown(e, "row")}
key={`row-${index}-${question.rows.length}`}>
key={`row-${index}`}>
<QuestionFormInput
id={`row-${index}`}
label={""}
@@ -219,7 +219,7 @@ export const MatrixQuestionForm = ({
<div
className="flex items-center"
onKeyDown={(e) => handleKeyDown(e, "column")}
key={`column-${index}-${question.columns.length}`}>
key={`column-${index}`}>
<QuestionFormInput
id={`column-${index}`}
label={""}

View File

@@ -148,7 +148,7 @@ export const AddIntegrationModal = ({
{
id: "createdAt",
name: t("common.created_at"),
type: TSurveyQuestionTypeEnum.OpenText,
type: TSurveyQuestionTypeEnum.Date,
},
];

View File

@@ -7,7 +7,6 @@ export const TYPE_MAPPING = {
[TSurveyQuestionTypeEnum.OpenText]: [
"created_by",
"created_time",
"date",
"email",
"last_edited_by",
"last_edited_time",

View File

@@ -48,7 +48,9 @@ export const MatrixQuestionSummary = ({
return "";
};
const columns = questionSummary.data[0] ? Object.keys(questionSummary.data[0].columnPercentages) : [];
const columns = questionSummary.data[0]
? questionSummary.data[0].columnPercentages.map((c) => c.column)
: [];
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
@@ -81,7 +83,7 @@ export const MatrixQuestionSummary = ({
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
</TooltipRenderer>
</td>
{Object.entries(columnPercentages).map(([column, percentage]) => (
{columnPercentages.map(({ column, percentage }) => (
<td
key={column}
className="text-center text-slate-500 dark:border-slate-700 dark:text-slate-400">

View File

@@ -778,13 +778,15 @@ export const getQuestionSummary = async (
totalResponsesForRow += countMap[row][col];
});
const columnPercentages = columns.reduce((acc, col) => {
const columnPercentages = columns.map((col) => {
const count = countMap[row][col];
const percentage =
totalResponsesForRow > 0 ? ((count / totalResponsesForRow) * 100).toFixed(2) : "0.00";
acc[col] = percentage;
return acc;
}, {});
return {
column: col,
percentage: Number(percentage),
};
});
return { rowLabel: row, columnPercentages, totalResponsesForRow };
});

View File

@@ -1,16 +1,16 @@
import { responses } from "@/app/lib/api/response";
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentIdFromApiKey } from "./lib/api-key";
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (apiKey) {
const apiKeyData = await getApiKeyFromKey(apiKey);
if (apiKeyData) {
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (environmentId) {
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId: apiKeyData.environmentId,
environmentId,
};
return authentication;
}

View File

@@ -0,0 +1,50 @@
import { apiKeyCache } from "@/lib/cache/api-key";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { getHash } from "@formbricks/lib/crypto";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { InvalidInputError } from "@formbricks/types/errors";
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise<string | null> => {
const hashedKey = getHash(apiKey);
return cache(
async () => {
validateInputs([apiKey, ZString]);
if (!apiKey) {
throw new InvalidInputError("API key cannot be null or undefined.");
}
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey,
},
select: {
environmentId: true,
},
});
if (!apiKeyData) {
throw new ResourceNotFoundError("apiKey", apiKey);
}
return apiKeyData.environmentId;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getEnvironmentIdFromApiKey-${apiKey}`],
{
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
}
)();
});

View File

@@ -1,6 +1,6 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { responses } from "@/app/lib/api/response";
import { headers } from "next/headers";
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
import { deleteWebhook, getWebhook } from "@formbricks/lib/webhook/service";
export const GET = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => {
@@ -10,8 +10,8 @@ export const GET = async (_: Request, props: { params: Promise<{ webhookId: stri
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const apiKeyData = await getApiKeyFromKey(apiKey);
if (!apiKeyData) {
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse();
}
@@ -20,7 +20,7 @@ export const GET = async (_: Request, props: { params: Promise<{ webhookId: stri
if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId);
}
if (webhook.environmentId !== apiKeyData.environmentId) {
if (webhook.environmentId !== environmentId) {
return responses.unauthorizedResponse();
}
return responses.successResponse(webhook);
@@ -33,8 +33,8 @@ export const DELETE = async (_: Request, props: { params: Promise<{ webhookId: s
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const apiKeyData = await getApiKeyFromKey(apiKey);
if (!apiKeyData) {
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse();
}
@@ -43,7 +43,7 @@ export const DELETE = async (_: Request, props: { params: Promise<{ webhookId: s
if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId);
}
if (webhook.environmentId !== apiKeyData.environmentId) {
if (webhook.environmentId !== environmentId) {
return responses.unauthorizedResponse();
}

View File

@@ -1,7 +1,7 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { headers } from "next/headers";
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
import { createWebhook, getWebhooks } from "@formbricks/lib/webhook/service";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { ZWebhookInput } from "@formbricks/types/webhooks";
@@ -12,14 +12,14 @@ export const GET = async () => {
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const apiKeyData = await getApiKeyFromKey(apiKey);
if (!apiKeyData) {
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse();
}
// get webhooks from database
try {
const webhooks = await getWebhooks(apiKeyData.environmentId);
const webhooks = await getWebhooks(environmentId);
return Response.json({ data: webhooks });
} catch (error) {
if (error instanceof DatabaseError) {
@@ -35,8 +35,8 @@ export const POST = async (request: Request) => {
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const apiKeyData = await getApiKeyFromKey(apiKey);
if (!apiKeyData) {
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse();
}
const webhookInput = await request.json();
@@ -52,7 +52,7 @@ export const POST = async (request: Request) => {
// add webhook to database
try {
const webhook = await createWebhook(apiKeyData.environmentId, inputValidation.data);
const webhook = await createWebhook(environmentId, inputValidation.data);
return responses.successResponse(webhook);
} catch (error) {
if (error instanceof InvalidInputError) {

View File

@@ -0,0 +1,35 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { gethasNoOrganizations } from "@formbricks/lib/instance/service";
import { createMembership } from "@formbricks/lib/membership/service";
import { createOrganization } from "@formbricks/lib/organization/service";
import { OperationNotAllowedError } from "@formbricks/types/errors";
const ZCreateOrganizationAction = z.object({
organizationName: z.string(),
});
export const createOrganizationAction = authenticatedActionClient
.schema(ZCreateOrganizationAction)
.action(async ({ ctx, parsedInput }) => {
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!hasNoOrganizations && !isMultiOrgEnabled) {
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
}
const newOrganization = await createOrganization({
name: parsedInput.organizationName,
});
await createMembership(newOrganization.id, ctx.user.id, {
role: "owner",
accepted: true,
});
return newOrganization;
});

View File

@@ -1,6 +1,6 @@
"use client";
import { createOrganizationAction } from "@/modules/organization/actions";
import { createOrganizationAction } from "@/app/setup/organization/create/actions";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";

View File

@@ -1,12 +1,12 @@
"use server";
import { apiKeyCache } from "@/lib/cache/api-key";
import { contactCache } from "@/lib/cache/contact";
import { teamCache } from "@/lib/cache/team";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { actionClassCache } from "@formbricks/lib/actionClass/cache";
import { apiKeyCache } from "@formbricks/lib/apiKey/cache";
import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { environmentCache } from "@formbricks/lib/environment/cache";

View File

@@ -18,9 +18,11 @@ export const CsvTable = ({ data }: CsvTableProps) => {
className="grid gap-4 border-b-2 border-slate-100 bg-slate-100 p-4 text-left"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}>
{columns.map((header, index) => (
<div key={index} className="font-semibold capitalize">
<span
key={index}
className="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold capitalize">
{header.replace(/_/g, " ")}
</div>
</span>
))}
</div>
@@ -30,9 +32,9 @@ export const CsvTable = ({ data }: CsvTableProps) => {
className="grid gap-4 border-b border-gray-200 bg-white p-4 text-left last:border-b-0"
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}>
{columns.map((header, colIndex) => (
<div key={colIndex} className="overflow-hidden text-ellipsis whitespace-nowrap">
<span key={colIndex} className="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{row[header]}
</div>
</span>
))}
</div>
))}

View File

@@ -8,10 +8,10 @@ import {
getProjectIdFromApiKeyId,
getProjectIdFromEnvironmentId,
} from "@/lib/utils/helper";
import { createApiKey, deleteApiKey } from "@/modules/projects/settings/lib/api-key";
import { createApiKey, deleteApiKey } from "@/modules/projects/settings/api-keys/lib/api-key";
import { z } from "zod";
import { ZApiKeyCreateInput } from "@formbricks/types/api-keys";
import { ZId } from "@formbricks/types/common";
import { ZApiKeyCreateInput } from "./types/api-keys";
const ZDeleteApiKeyAction = z.object({
id: ZId,

View File

@@ -1,5 +1,5 @@
import { getApiKeys } from "@/modules/projects/settings/api-keys/lib/api-key";
import { getTranslations } from "next-intl/server";
import { getApiKeys } from "@formbricks/lib/apiKey/service";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { TUserLocale } from "@formbricks/types/user";

View File

@@ -1,6 +1,7 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TApiKey } from "@/modules/projects/settings/api-keys/types/api-keys";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FilesIcon, TrashIcon } from "lucide-react";
@@ -8,7 +9,6 @@ import { useTranslations } from "next-intl";
import { useState } from "react";
import toast from "react-hot-toast";
import { timeSince } from "@formbricks/lib/time";
import { TApiKey } from "@formbricks/types/api-keys";
import { TUserLocale } from "@formbricks/types/user";
import { createApiKeyAction, deleteApiKeyAction } from "../actions";
import { AddApiKeyModal } from "./add-api-key-modal";
@@ -60,7 +60,6 @@ export const EditAPIKeys = ({
environmentId: environmentTypeId,
apiKeyData: { label: data.label },
});
console.log("createApiKeyResponse", createApiKeyResponse);
if (createApiKeyResponse?.data) {
const updatedApiKeys = [...apiKeysLocal!, createApiKeyResponse.data];
setApiKeysLocal(updatedApiKeys);

View File

@@ -1,14 +1,48 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { apiKeyCache } from "@/lib/cache/api-key";
import { TApiKeyCreateInput, ZApiKeyCreateInput } from "@/modules/projects/settings/api-keys/types/api-keys";
import { TApiKey } from "@/modules/projects/settings/api-keys/types/api-keys";
import { ApiKey, Prisma } from "@prisma/client";
import { createHash, randomBytes } from "crypto";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { apiKeyCache } from "@formbricks/lib/apiKey/cache";
import { cache } from "@formbricks/lib/cache";
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { TApiKey, TApiKeyCreateInput, ZApiKeyCreateInput } from "@formbricks/types/api-keys";
import { ZId } from "@formbricks/types/common";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
export const deleteApiKey = async (id: string): Promise<TApiKey | null> => {
export const getApiKeys = reactCache(
async (environmentId: string, page?: number): Promise<ApiKey[]> =>
cache(
async () => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const apiKeys = await prisma.apiKey.findMany({
where: {
environmentId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return apiKeys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getApiKeys-${environmentId}-${page}`],
{
tags: [apiKeyCache.tag.byEnvironmentId(environmentId)],
}
)()
);
export const deleteApiKey = async (id: string): Promise<ApiKey | null> => {
validateInputs([id, ZId]);
try {

View File

@@ -0,0 +1,15 @@
import { ApiKey } from "@prisma/client";
import { z } from "zod";
import { ZApiKey } from "@formbricks/database/zod/api-keys";
export const ZApiKeyCreateInput = ZApiKey.required({
label: true,
}).pick({
label: true,
});
export type TApiKeyCreateInput = z.infer<typeof ZApiKeyCreateInput>;
export interface TApiKey extends ApiKey {
apiKey?: string;
}

View File

@@ -163,7 +163,6 @@ export const ThemeStyling = ({
setOpen={setBackgroundStylingOpen}
environmentId={environmentId}
colors={colors}
key={form.watch("background.bg")}
isSettingsPage
isUnsplashConfigured={isUnsplashConfigured}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}

View File

@@ -385,7 +385,7 @@ export const QuestionFormInput = ({
{recallComponents}
</div>
<div>
<>
{id === "headline" && !isWelcomeCard && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.add_photo_or_video")}>
<Button
@@ -418,7 +418,7 @@ export const QuestionFormInput = ({
</Button>
</TooltipRenderer>
)}
</div>
</>
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "3.1.0",
"version": "3.1.2",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -129,7 +129,7 @@
"@types/nodemailer": "6.4.17",
"@types/papaparse": "5.3.15",
"@types/qrcode": "1.5.5",
"vite": "6.0.3",
"vite": "6.0.9",
"vitest": "2.1.8",
"vitest-mock-extended": "2.0.2"
}

View File

@@ -1,125 +1,144 @@
import { expect } from "@playwright/test";
import http from "http";
import { test } from "./lib/fixtures";
import { replaceEnvironmentIdInHtml } from "./utils/helper";
const HTML_TEMPLATE = `<head>
<script type="text/javascript">
!(function () {
var t = document.createElement("script");
(t.type = "text/javascript"), (t.async = !0), (t.src = "http://localhost:3000/js/formbricks.umd.cjs");
var e = document.getElementsByTagName("script")[0];
e.parentNode.insertBefore(t, e),
setTimeout(function () {
formbricks.init({
environmentId: "ENVIRONMENT_ID",
userId: "RANDOM_USER_ID",
apiHost: "http://localhost:3000",
});
}, 500);
})();
</script>
</head>
<body style="background-color: #fff">
<p>This is my sample page using the Formbricks JS javascript widget</p>
</body>
`;
test.describe("JS Package Test", async () => {
let server: http.Server;
let environmentId: string;
test("Tests", async ({ page, users }) => {
await test.step("Admin creates an In-App Survey", async () => {
const user = await users.create();
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await page.getByRole("heading", { name: "Product Market Fit (Superhuman)" }).isVisible();
await page.getByRole("heading", { name: "Product Market Fit (Superhuman)" }).click();
await page.getByRole("button", { name: "Use this template" }).isVisible();
await page.getByRole("button", { name: "Use this template" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit/);
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page.locator("#howToSendCardTrigger")).toBeVisible();
await page.locator("#howToSendCardTrigger").click();
await expect(page.locator("#howToSendCardOption-app")).toBeVisible();
await page.locator("#howToSendCardOption-app").click();
await page.locator("#whenToSendCardTrigger").click();
await page.getByRole("button", { name: "Add action" }).click();
await page.getByText("New SessionGets fired when a").click();
await page.locator("#recontactOptionsCardTrigger").click();
await page.locator("label").filter({ hasText: "Keep showing while conditions" }).click();
await page.locator("#recontactDays").check();
await page.getByRole("button", { name: "Publish" }).click();
environmentId =
/\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
(() => {
throw new Error("Unable to parse environmentId from URL");
})();
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary/);
test.beforeAll(async () => {
// Create a simple HTTP server
server = http.createServer((_, res) => {
const htmlContent = HTML_TEMPLATE.replace("ENVIRONMENT_ID", environmentId || "");
res.writeHead(200, { "Content-Type": "text/html" });
res.end(htmlContent);
});
await test.step("JS display survey on page and submit response", async () => {
let currentDir = process.cwd();
let htmlFilePath = currentDir + "/packages/js/index.html";
let htmlFile = replaceEnvironmentIdInHtml(htmlFilePath, environmentId);
await page.goto(htmlFile);
// Formbricks In App Sync has happened
const syncApi = await page.waitForResponse(
(response) => {
return response.url().includes("/environment");
},
{
timeout: 120000,
}
);
expect(syncApi.status()).toBe(200);
// Formbricks Modal exists in the DOM
await expect(page.locator("#formbricks-modal-container")).toHaveCount(1);
// Formbricks Modal is visible
await expect(
page.locator("#questionCard-0").getByRole("link", { name: "Powered by Formbricks" })
).toBeVisible();
// Fill the Survey
await page.getByRole("button", { name: "Happy to help!" }).click();
await page.locator("label").filter({ hasText: "Somewhat disappointed" }).click();
await page.locator("#questionCard-1").getByRole("button", { name: "Next" }).click();
await page.locator("label").filter({ hasText: "Founder" }).click();
await page.locator("#questionCard-2").getByRole("button", { name: "Next" }).click();
await page
.locator("#questionCard-3")
.getByLabel("textarea")
.fill("People who believe that PMF is necessary");
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
await page.locator("#questionCard-4").getByLabel("textarea").fill("Much higher response rates!");
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
await page.locator("#questionCard-5").getByLabel("textarea").fill("Make this end to end test pass!");
await page.getByRole("button", { name: "Finish" }).click();
// loading spinner -> wait for it to disappear
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
await page.waitForLoadState("networkidle");
});
await test.step("Admin validates Displays & Response", async () => {
await page.goto("/");
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await page.getByRole("link", { name: "product Market Fit (Superhuman)" }).click();
(await page.waitForSelector("text=Responses")).isVisible();
await page.waitForLoadState("networkidle");
await page.waitForTimeout(2000);
const impressionsCount = await page.getByRole("button", { name: "Impressions" }).innerText();
expect(impressionsCount).toEqual("Impressions\n\n1");
await expect(page.getByRole("link", { name: "Responses (1)" })).toBeVisible();
await expect(page.getByRole("button", { name: "Completed 100%" })).toBeVisible();
await expect(page.getByText("1 Responses", { exact: true }).first()).toBeVisible();
await expect(page.getByText("CTR100%")).toBeVisible();
await expect(page.getByText("Somewhat disappointed100%")).toBeVisible();
await expect(page.getByText("Founder100%")).toBeVisible();
await expect(page.getByText("People who believe that PMF").first()).toBeVisible();
await expect(page.getByText("Much higher response rates!").first()).toBeVisible();
await expect(page.getByText("Make this end to end test").first()).toBeVisible();
await new Promise<void>((resolve) => {
server.listen(3004, () => resolve());
});
});
test.afterAll(async () => {
// Cleanup: close the server
await new Promise((resolve) => server.close(resolve));
});
test("Create, display and validate PMF survey", async ({ page, users }) => {
// Create and login user
const user = await users.create();
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
// Extract environmentId early in the test
environmentId =
/\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
(() => {
throw new Error("Unable to parse environmentId from URL");
})();
// Create survey from template
await page.getByRole("heading", { name: "Product Market Fit (Superhuman)" }).isVisible();
await page.getByRole("heading", { name: "Product Market Fit (Superhuman)" }).click();
await page.getByRole("button", { name: "Use this template" }).isVisible();
await page.getByRole("button", { name: "Use this template" }).click();
// Configure survey settings
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit/);
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page.locator("#howToSendCardTrigger")).toBeVisible();
await page.locator("#howToSendCardTrigger").click();
await expect(page.locator("#howToSendCardOption-app")).toBeVisible();
await page.locator("#howToSendCardOption-app").click();
await page.locator("#whenToSendCardTrigger").click();
await page.getByRole("button", { name: "Add action" }).click();
await page.getByText("New SessionGets fired when a").click();
await page.locator("#recontactOptionsCardTrigger").click();
await page.locator("label").filter({ hasText: "Keep showing while conditions" }).click();
await page.locator("#recontactDays").check();
await page.getByRole("button", { name: "Publish" }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary/);
// No need for file operations anymore, just use the server
await page.goto("http://localhost:3004");
const syncApi = await page.waitForResponse((response) => response.url().includes("/environment"), {
timeout: 120000,
});
expect(syncApi.status()).toBe(200);
await expect(page.locator("#formbricks-modal-container")).toHaveCount(1);
await expect(
page.locator("#questionCard-0").getByRole("link", { name: "Powered by Formbricks" })
).toBeVisible();
// Fill the survey
await page.getByRole("button", { name: "Happy to help!" }).click();
await page.locator("label").filter({ hasText: "Somewhat disappointed" }).click();
await page.locator("#questionCard-1").getByRole("button", { name: "Next" }).click();
await page.locator("label").filter({ hasText: "Founder" }).click();
await page.locator("#questionCard-2").getByRole("button", { name: "Next" }).click();
await page
.locator("#questionCard-3")
.getByLabel("textarea")
.fill("People who believe that PMF is necessary");
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
await page.locator("#questionCard-4").getByLabel("textarea").fill("Much higher response rates!");
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
await page.locator("#questionCard-5").getByLabel("textarea").fill("Make this end to end test pass!");
await page.getByRole("button", { name: "Finish" }).click();
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
await page.waitForLoadState("networkidle");
// Validate displays and response
await page.goto("/");
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await page.getByRole("link", { name: "product Market Fit (Superhuman)" }).click();
await page.waitForSelector("text=Responses");
await page.waitForLoadState("networkidle");
await page.waitForTimeout(2000);
const impressionsCount = await page.getByRole("button", { name: "Impressions" }).innerText();
expect(impressionsCount).toEqual("Impressions\n\n1");
await expect(page.getByRole("link", { name: "Responses (1)" })).toBeVisible();
await expect(page.getByRole("button", { name: "Completed 100%" })).toBeVisible();
await expect(page.getByText("1 Responses", { exact: true }).first()).toBeVisible();
await expect(page.getByText("CTR100%")).toBeVisible();
await expect(page.getByText("Somewhat disappointed100%")).toBeVisible();
await expect(page.getByText("Founder100%")).toBeVisible();
await expect(page.getByText("People who believe that PMF").first()).toBeVisible();
await expect(page.getByText("Much higher response rates!").first()).toBeVisible();
await expect(page.getByText("Make this end to end test").first()).toBeVisible();
});
});

View File

@@ -12,8 +12,6 @@ test.use({
test.describe("Survey Create & Submit Response without logic", async () => {
let url: string | null;
test.slow();
test("Create survey and submit response", async ({ page, users }) => {
const user = await users.create();
await user.login();
@@ -615,7 +613,7 @@ test.describe("Multi Language Survey Create", async () => {
});
test.describe("Testing Survey with advanced logic", async () => {
test.setTimeout(240000);
test.setTimeout(300000);
let url: string | null;
test("Create survey and submit response", async ({ page, users }) => {

View File

@@ -43,7 +43,7 @@ x-environment: &environment
# SMTP_AUTHENTICATED:
# (Additional option for TLS (port 465) only)
# SMTP_SECURE_ENABLED: 1
# SMTP_SECURE_ENABLED:
# If set to 0, the server will accept connections without requiring authorization from the list of supplied CAs (default is 1).
# SMTP_REJECT_UNAUTHORIZED_TLS: 0

View File

@@ -27,11 +27,13 @@
"release": "turbo run build --filter=@formbricks/js... && changeset publish",
"test": "turbo run test --no-cache",
"test:e2e": "playwright test",
"test-e2e:azure": "pnpm test:e2e -c playwright.service.config.ts --workers=20",
"prepare": "husky install",
"storybook": "turbo run storybook",
"fb-migrate-dev": "pnpm --filter @formbricks/database create-migration && pnpm prisma generate"
},
"devDependencies": {
"@azure/microsoft-playwright-testing": "1.0.0-beta.6",
"@formbricks/eslint-config": "workspace:*",
"@playwright/test": "1.49.1",
"eslint": "8.57.0",

View File

@@ -40,7 +40,7 @@
"@rollup/plugin-inject": "5.0.5",
"buffer": "6.0.3",
"terser": "5.37.0",
"vite": "6.0.3",
"vite": "6.0.9",
"vite-plugin-dts": "4.3.0",
"vite-plugin-node-polyfills": "0.22.0"
}

View File

@@ -1,3 +1,4 @@
import { type ApiKey } from "@prisma/client";
import { z } from "zod";
export const ZApiKey = z.object({
@@ -7,12 +8,4 @@ export const ZApiKey = z.object({
label: z.string().nullable(),
hashedKey: z.string(),
environmentId: z.string().cuid2(),
apiKey: z.string().optional(),
});
export type TApiKey = z.infer<typeof ZApiKey>;
export const ZApiKeyCreateInput = z.object({
label: z.string(),
});
export type TApiKeyCreateInput = z.infer<typeof ZApiKeyCreateInput>;
}) satisfies z.ZodType<ApiKey>;

View File

@@ -47,7 +47,7 @@
"@formbricks/types": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"terser": "5.37.0",
"vite": "6.0.3",
"vite": "6.0.9",
"vite-plugin-dts": "4.3.0"
}
}

View File

@@ -1,21 +0,0 @@
<head>
<script type="text/javascript">
!(function () {
var t = document.createElement("script");
(t.type = "text/javascript"), (t.async = !0), (t.src = "http://localhost:3000/js/formbricks.umd.cjs");
var e = document.getElementsByTagName("script")[0];
e.parentNode.insertBefore(t, e),
setTimeout(function () {
formbricks.init({
environmentId: "cm4tqiyd2000fibzprjfhsny2",
userId: "RANDOM_USER_ID",
apiHost: "http://localhost:3000",
});
}, 500);
})();
</script>
</head>
<body style="background-color: #fff">
<p>This is my sample page using the Formbricks JS javascript widget</p>
</body>

View File

@@ -45,7 +45,7 @@
"@formbricks/eslint-config": "workspace:*",
"@formbricks/types": "workspace:*",
"terser": "5.37.0",
"vite": "6.0.3",
"vite": "6.0.9",
"vite-plugin-dts": "4.3.0"
},
"peerDependencies": {

View File

@@ -1,29 +0,0 @@
import "server-only";
import { ZId } from "@formbricks/types/common";
import { cache } from "../cache";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { validateInputs } from "../utils/validate";
import { apiKeyCache } from "./cache";
import { getApiKey } from "./service";
export const canUserAccessApiKey = (userId: string, apiKeyId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [apiKeyId, ZId]);
try {
const apiKeyFromServer = await getApiKey(apiKeyId);
if (!apiKeyFromServer) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, apiKeyFromServer.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
} catch (error) {
throw error;
}
},
[`canUserAccessApiKey-${userId}-${apiKeyId}`],
{ tags: [apiKeyCache.tag.byId(apiKeyId)] }
)();

View File

@@ -1,109 +0,0 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { TApiKey } from "@formbricks/types/api-keys";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { cache } from "../cache";
import { ITEMS_PER_PAGE } from "../constants";
import { getHash } from "../crypto";
import { validateInputs } from "../utils/validate";
import { apiKeyCache } from "./cache";
export const getApiKey = reactCache(
async (apiKeyId: string): Promise<TApiKey | null> =>
cache(
async () => {
validateInputs([apiKeyId, ZString]);
if (!apiKeyId) {
throw new InvalidInputError("API key cannot be null or undefined.");
}
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
id: apiKeyId,
},
});
return apiKeyData;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getApiKey-${apiKeyId}`],
{
tags: [apiKeyCache.tag.byId(apiKeyId)],
}
)()
);
export const getApiKeys = reactCache(
async (environmentId: string, page?: number): Promise<TApiKey[]> =>
cache(
async () => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try {
const apiKeys = await prisma.apiKey.findMany({
where: {
environmentId,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return apiKeys;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getApiKeys-${environmentId}-${page}`],
{
tags: [apiKeyCache.tag.byEnvironmentId(environmentId)],
}
)()
);
export const getApiKeyFromKey = reactCache(async (apiKey: string): Promise<TApiKey | null> => {
const hashedKey = getHash(apiKey);
return cache(
async () => {
validateInputs([apiKey, ZString]);
if (!apiKey) {
throw new InvalidInputError("API key cannot be null or undefined.");
}
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey,
},
});
return apiKeyData;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getApiKeyFromKey-${apiKey}`],
{
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
}
)();
});

View File

@@ -79,7 +79,7 @@ export const MAIL_FROM = env.MAIL_FROM;
export const NEXTAUTH_SECRET = env.NEXTAUTH_SECRET;
export const ITEMS_PER_PAGE = 30;
export const SURVEYS_PER_PAGE = 12;
export const RESPONSES_PER_PAGE = 20;
export const RESPONSES_PER_PAGE = 25;
export const TEXT_RESPONSES_PER_PAGE = 5;
export const INSIGHTS_PER_PAGE = 10;
export const DOCUMENTS_PER_PAGE = 10;

View File

@@ -567,6 +567,7 @@
"this_action_will_be_triggered_when_the_page_is_loaded": "Diese Aktion wird ausgelöst, wenn die Seite geladen ist.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Diese Aktion wird ausgelöst, wenn der Benutzer 50% der Seite scrollt.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Diese Aktion wird ausgelöst, wenn der Benutzer versucht, die Seite zu verlassen.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Dies ist eine Code-Aktion. Bitte nehmen Sie Änderungen an Ihrem Code vor.",
"this_is_a_code_action_you_can_only_change_the_description": "Das ist eine Code-Aktion. Du kannst nur die Beschreibung ändern.",
"track_new_user_action": "Neue Benutzeraktion verfolgen",
"track_user_action_to_display_surveys_or_create_user_segment": "Benutzeraktionen verfolgen, um Umfragen anzuzeigen oder Benutzersegmente zu erstellen.",

View File

@@ -567,6 +567,7 @@
"this_action_will_be_triggered_when_the_page_is_loaded": "This action will be triggered when the page is loaded.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "This action will be triggered when the user scrolls 50% of the page.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "This action will be triggered when the user tries to leave the page.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "This is a code action. Please make changes in your code base.",
"this_is_a_code_action_you_can_only_change_the_description": "This is a code action. You can only change the description.",
"track_new_user_action": "Track New User Action",
"track_user_action_to_display_surveys_or_create_user_segment": "Track user action to display surveys or create user segment.",

View File

@@ -567,6 +567,7 @@
"this_action_will_be_triggered_when_the_page_is_loaded": "Cette action sera déclenchée lorsque la page sera chargée.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Cette action sera déclenchée lorsque l'utilisateur fera défiler 50 % de la page.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Cette action sera déclenchée lorsque l'utilisateur essaiera de quitter la page.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ceci est une action de code. Veuillez apporter des modifications à votre base de code.",
"this_is_a_code_action_you_can_only_change_the_description": "Ceci est une action de code. Vous ne pouvez changer que la description.",
"track_new_user_action": "Suivre l'action des nouveaux utilisateurs",
"track_user_action_to_display_surveys_or_create_user_segment": "Suivre l'action de l'utilisateur pour afficher des enquêtes ou créer un segment d'utilisateur.",

View File

@@ -567,6 +567,7 @@
"this_action_will_be_triggered_when_the_page_is_loaded": "Essa ação vai ser disparada quando a página carregar.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Essa ação vai ser acionada quando o usuário rolar 50% da página.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Essa ação será acionada quando o usuário tentar sair da página.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Esta é uma ação de código. Por favor, faça alterações na sua base de código.",
"this_is_a_code_action_you_can_only_change_the_description": "Essa é uma ação de código. Você só pode mudar a descrição.",
"track_new_user_action": "Rastrear Ação de Novo Usuário",
"track_user_action_to_display_surveys_or_create_user_segment": "Rastrear ações do usuário para exibir pesquisas ou criar segmento de usuários.",

View File

@@ -18,6 +18,7 @@
".",
"../types/*.d.ts",
"../../apps/web/lib/cache/contact-attribute-key.ts",
"../../apps/web/modules/utils/hooks"
"../../apps/web/modules/utils/hooks",
"../../apps/web/lib/cache/api-key.ts"
]
}

View File

@@ -48,7 +48,7 @@
"react": "18.3.1",
"react-native": "0.74.5",
"terser": "5.37.0",
"vite": "6.0.3",
"vite": "6.0.9",
"vite-plugin-dts": "4.3.0"
},
"peerDependencies": {

View File

@@ -52,7 +52,7 @@
"serve": "14.2.4",
"tailwindcss": "3.4.16",
"terser": "5.37.0",
"vite": "6.0.3",
"vite": "6.0.9",
"vite-plugin-dts": "4.3.0",
"vite-tsconfig-paths": "5.1.4"
},

View File

@@ -1,5 +1,5 @@
import { ButtonHTMLAttributes } from "preact/compat";
import { useCallback } from "preact/hooks";
import { ButtonHTMLAttributes, useRef } from "preact/compat";
import { useCallback, useEffect } from "preact/hooks";
interface SubmitButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
buttonLabel: string | undefined;
@@ -17,17 +17,34 @@ export function SubmitButton({
type,
...props
}: SubmitButtonProps) {
const buttonRef = useCallback(
(currentButton: HTMLButtonElement | null) => {
if (currentButton && focus) {
setTimeout(() => {
currentButton.focus();
}, 200);
const buttonRef = useRef<HTMLButtonElement>(null);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && !disabled) {
event.preventDefault();
const button = buttonRef.current;
if (button) {
button.click();
}
}
},
[focus]
[disabled]
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
useEffect(() => {
if (buttonRef.current && focus) {
setTimeout(() => {
buttonRef.current?.focus();
}, 200);
}
}, [focus]);
return (
<button
{...props}

View File

@@ -57,25 +57,30 @@ export function EndingCard({
</div>
);
const processAndRedirect = (urlString: string) => {
try {
const url = replaceRecallInfo(urlString, responseData, variablesData);
if (url && new URL(url)) {
window.top?.location.replace(url);
}
} catch (error) {
console.error("Invalid URL after recall processing:", error);
}
};
const handleSubmit = () => {
if (!isRedirectDisabled && endingCard.type === "endScreen" && endingCard.buttonLink) {
window.top?.location.replace(endingCard.buttonLink);
processAndRedirect(endingCard.buttonLink);
}
};
useEffect(() => {
if (isCurrent) {
if (!isRedirectDisabled && endingCard.type === "redirectToUrl" && endingCard.url) {
try {
const url = replaceRecallInfo(endingCard.url, responseData, variablesData);
if (url && new URL(url)) {
window.top?.location.replace(url);
}
} catch (error) {
console.error("Invalid URL after recall processing:", error);
}
processAndRedirect(endingCard.url);
}
}
const handleEnter = (e: KeyboardEvent) => {
if (e.key === "Enter") {
handleSubmit();

View File

@@ -32,7 +32,7 @@ export const StackedCard = ({
cardArrangement,
}: StackedCardProps) => {
const isHidden = offset < 0;
const [delayedOffset, setDelayedOffset] = useState<number>(0);
const [delayedOffset, setDelayedOffset] = useState<number>(offset);
const [contentOpacity, setContentOpacity] = useState<number>(0);
const currentCardHeight = offset === 0 ? "auto" : offset < 0 ? "initial" : cardHeight;

View File

@@ -1,11 +0,0 @@
import { z } from "zod";
import { ZId } from "./common";
export const ZContact = z.object({
id: ZId,
createdAt: z.date(),
updatedAt: z.date(),
environmentId: ZId,
});
export type TContact = z.infer<typeof ZContact>;

View File

@@ -20,12 +20,6 @@ export const ZDisplayCreateInput = z.object({
export type TDisplayCreateInput = z.infer<typeof ZDisplayCreateInput>;
export const ZDisplaysWithSurveyName = ZDisplay.extend({
surveyName: z.string(),
});
export type TDisplaysWithSurveyName = z.infer<typeof ZDisplaysWithSurveyName>;
export const ZDisplayFilters = z.object({
createdAt: z
.object({

View File

@@ -2573,7 +2573,12 @@ export const ZSurveyQuestionSummaryMatrix = z.object({
data: z.array(
z.object({
rowLabel: z.string(),
columnPercentages: z.record(z.string(), z.number()),
columnPercentages: z.array(
z.object({
column: z.string(),
percentage: z.number(),
})
),
totalResponsesForRow: z.number(),
})
),

View File

@@ -17,6 +17,6 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"vite": "6.0.3"
"vite": "6.0.9"
}
}

View File

@@ -0,0 +1,22 @@
import { ServiceOS, getServiceConfig } from "@azure/microsoft-playwright-testing";
import { defineConfig } from "@playwright/test";
import config from "./playwright.config";
/* Learn more about service configuration at https://aka.ms/mpt/config */
export default defineConfig(
config,
getServiceConfig(config, {
exposeNetwork: "<loopback>",
timeout: 30000,
os: ServiceOS.LINUX,
useCloudHostedBrowsers: true, // Set to false if you want to only use reporting and not cloud hosted browsers
}),
{
/*
Playwright Testing service reporter is added by default.
This will override any reporter options specified in the base playwright config.
If you are using more reporters, please update your configuration accordingly.
*/
reporter: [["list"], ["@azure/microsoft-playwright-testing/reporter"]],
}
);

1049
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff