Compare commits

..

3 Commits

Author SHA1 Message Date
Bhagya Amarasinghe 172d3a9e68 chore: trigger duplicate PR checks 2026-05-13 00:47:18 +05:30
Bhagya Amarasinghe 6b1b2c8e81 fix: address Docker setup review follow-ups 2026-05-12 11:59:44 +05:30
Bhagya Amarasinghe f747af59f4 fix: preserve existing Docker installs in setup script 2026-05-12 11:22:22 +05:30
53 changed files with 1925 additions and 2437 deletions
+8 -8
View File
@@ -12,18 +12,18 @@
},
"devDependencies": {
"@chromatic-com/storybook": "5.0.2",
"@storybook/addon-a11y": "10.3.6",
"@storybook/addon-docs": "10.3.6",
"@storybook/addon-links": "10.3.6",
"@storybook/addon-onboarding": "10.3.6",
"@storybook/react-vite": "10.3.6",
"@storybook/addon-a11y": "10.3.5",
"@storybook/addon-docs": "10.3.5",
"@storybook/addon-links": "10.3.5",
"@storybook/addon-onboarding": "10.3.5",
"@storybook/react-vite": "10.3.5",
"@tailwindcss/vite": "4.2.4",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.3.6",
"storybook": "10.3.6",
"vite": "7.3.3"
"eslint-plugin-storybook": "10.3.5",
"storybook": "10.3.5",
"vite": "7.3.2"
}
}
@@ -1,12 +1,7 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import {
EMAIL_VERIFICATION_DISABLED,
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
PASSWORD_RESET_DISABLED,
} from "@/lib/constants";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
@@ -76,7 +71,7 @@ const Page = async (props: {
: t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${params.environmentId}/settings/billing`
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
@@ -3,7 +3,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -173,7 +173,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</p>
<Button asChild>
<Link
href={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
target="_blank"
rel="noopener noreferrer nofollow"
referrerPolicy="no-referrer">
@@ -1,11 +1,6 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { isInstanceAIConfigured } from "@/lib/ai/service";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
FB_LOGO_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
} from "@/lib/constants";
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -85,7 +80,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
fbLogoUrl={FB_LOGO_URL}
user={user}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
{isMultiOrgEnabled && (
<SettingsCard
@@ -2,12 +2,7 @@ import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/er
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
RESPONSES_PER_PAGE,
} from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -77,7 +72,6 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
}>
<SurveyAnalysisNavigation activeId="responses" />
@@ -31,7 +31,6 @@ interface SurveyAnalysisCTAProps {
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
isStorageConfigured: boolean;
enterpriseLicenseRequestFormUrl: string;
}
interface ModalState {
@@ -48,7 +47,6 @@ export const SurveyAnalysisCTA = ({
isContactsEnabled,
isFormbricksCloud,
isStorageConfigured,
enterpriseLicenseRequestFormUrl,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslation();
const router = useRouter();
@@ -233,7 +231,6 @@ export const SurveyAnalysisCTA = ({
isReadOnly={isReadOnly}
isStorageConfigured={isStorageConfigured}
projectCustomScripts={project.customHeadScripts}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
/>
)}
<SuccessMessage />
@@ -54,7 +54,6 @@ interface ShareSurveyModalProps {
isReadOnly: boolean;
isStorageConfigured: boolean;
projectCustomScripts?: string | null;
enterpriseLicenseRequestFormUrl: string;
}
export const ShareSurveyModal = ({
@@ -70,7 +69,6 @@ export const ShareSurveyModal = ({
isReadOnly,
isStorageConfigured,
projectCustomScripts,
enterpriseLicenseRequestFormUrl,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
@@ -110,7 +108,6 @@ export const ShareSurveyModal = ({
segments,
isContactsEnabled,
isFormbricksCloud,
enterpriseLicenseRequestFormUrl,
},
disabled: survey.singleUse?.enabled,
},
@@ -34,7 +34,6 @@ interface PersonalLinksTabProps {
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
enterpriseLicenseRequestFormUrl: string;
}
interface PersonalLinksFormData {
@@ -75,7 +74,6 @@ export const PersonalLinksTab = ({
surveyId,
isContactsEnabled,
isFormbricksCloud,
enterpriseLicenseRequestFormUrl,
}: PersonalLinksTabProps) => {
const { t } = useTranslation();
@@ -171,7 +169,7 @@ export const PersonalLinksTab = ({
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: enterpriseLicenseRequestFormUrl,
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
@@ -1106,21 +1106,6 @@ describe("getSurveySummary", () => {
expect.objectContaining({ responseIds: expect.any(Array) })
);
});
test("does not pass responseIds for date-only filterCriteria", async () => {
const filterCriteria: TResponseFilterCriteria = {
createdAt: {
min: new Date("2024-01-01T00:00:00.000Z"),
max: new Date("2024-01-31T23:59:59.999Z"),
},
};
await getSurveySummary(mockSurveyId, filterCriteria);
expect(getDisplayCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, {
createdAt: filterCriteria.createdAt,
});
});
});
describe("getResponsesForSummary", () => {
@@ -979,7 +979,7 @@ export const getSurveySummary = reactCache(
const elements = getElementsFromBlocks(survey.blocks);
const batchSize = 5000;
const hasFilter = Object.keys(filterCriteria ?? {}).some((filterKey) => filterKey !== "createdAt");
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
// Use cursor-based pagination instead of count + offset to avoid expensive queries
const responses: TSurveySummaryResponse[] = [];
@@ -4,12 +4,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import {
DEFAULT_LOCALE,
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
} from "@/lib/constants";
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
@@ -79,7 +74,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
}>
<SurveyAnalysisNavigation activeId="summary" />
+14 -33
View File
@@ -1,6 +1,5 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import type { Agent } from "undici";
import { v7 as uuidv7 } from "uuid";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -17,7 +16,7 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { createPinnedDispatcher, validateAndResolveWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
@@ -99,19 +98,11 @@ export const POST = async (request: Request) => {
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
// pre-patch redirect-follow behavior for consistency.
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
// Uses AbortSignal to actually cancel the underlying fetch when the timer fires —
// a Promise.race would only reject the wrapper while the fetch keeps the socket
// open, which then deadlocks dispatcher.close() (graceful drain waits for it).
const fetchWithTimeout = (
url: string,
options: RequestInit & { dispatcher?: Agent },
timeout: number = 5000
): Promise<Response> => {
return fetch(url, {
...options,
redirect: redirectMode,
signal: AbortSignal.timeout(timeout),
} as RequestInit & { dispatcher?: Agent });
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
return Promise.race([
fetch(url, { ...options, redirect: redirectMode }),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
@@ -153,24 +144,14 @@ export const POST = async (request: Request) => {
);
}
return validateAndResolveWebhookUrl(webhook.url)
.then(async (address) => {
// Pin TCP connect to the validated IP. Without this, undici resolves DNS
// again at fetch time and an attacker-controlled domain can rebind to a
// private/internal IP after validation passed (TOCTOU SSRF).
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
try {
return await fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
dispatcher,
});
} finally {
// destroy() — not close() — force-kills sockets and rejects any in-flight request
await dispatcher?.destroy();
}
})
return validateWebhookUrl(webhook.url)
.then(() =>
fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
})
)
.catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
-3
View File
@@ -155,9 +155,6 @@ export const DEBUG = env.DEBUG === "1";
// Enterprise License constant
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
export const ENTERPRISE_LICENSE_REQUEST_FORM_URL =
"https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=ce";
export const REDIS_URL = env.REDIS_URL;
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
export const TELEMETRY_DISABLED = env.TELEMETRY_DISABLED === "1";
+3 -16
View File
@@ -4,7 +4,7 @@ import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { TDisplay, TDisplayFilters, TDisplayWithContact, ZDisplayFilters } from "@formbricks/types/displays";
import { TDisplay, TDisplayFilters, TDisplayWithContact } from "@formbricks/types/displays";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "../utils/validate";
@@ -18,31 +18,18 @@ export const selectDisplay = {
export const getDisplayCountBySurveyId = reactCache(
async (surveyId: string, filters?: TDisplayFilters): Promise<number> => {
validateInputs([surveyId, ZId], [filters, ZDisplayFilters.optional()]);
if (filters?.responseIds?.length === 0) {
return 0;
}
validateInputs([surveyId, ZId]);
try {
const displayCount = await prisma.display.count({
where: {
surveyId,
surveyId: surveyId,
...(filters?.createdAt && {
createdAt: {
gte: filters.createdAt.min,
lte: filters.createdAt.max,
},
}),
...(filters?.responseIds && {
response: {
is: {
id: {
in: filters.responseIds,
},
},
},
}),
},
});
return displayCount;
@@ -4,14 +4,9 @@ import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import {
getDisplayCountBySurveyId,
getDisplaysByContactId,
getDisplaysBySurveyIdWithContact,
} from "../service";
import { getDisplaysByContactId, getDisplaysBySurveyIdWithContact } from "../service";
const mockContactId = "clqnj99r9000008lebgf8734j";
const mockResponseIds = ["clqnfg59i000208i426pb4wcv", "clqnfg59i000208i426pb4wcw"];
const mockDisplaysForContact = [
{
@@ -50,74 +45,6 @@ const mockDisplaysWithContact = [
},
];
describe("getDisplayCountBySurveyId", () => {
describe("Happy Path", () => {
test("counts displays by surveyId", async () => {
vi.mocked(prisma.display.count).mockResolvedValue(5);
const result = await getDisplayCountBySurveyId(mockSurveyId);
expect(result).toBe(5);
expect(prisma.display.count).toHaveBeenCalledWith({
where: {
surveyId: mockSurveyId,
},
});
});
test("combines createdAt and responseIds filters", async () => {
const createdAt = {
min: new Date("2024-01-01T00:00:00.000Z"),
max: new Date("2024-01-31T23:59:59.999Z"),
};
vi.mocked(prisma.display.count).mockResolvedValue(2);
const result = await getDisplayCountBySurveyId(mockSurveyId, {
createdAt,
responseIds: mockResponseIds,
});
expect(result).toBe(2);
expect(prisma.display.count).toHaveBeenCalledWith({
where: {
surveyId: mockSurveyId,
createdAt: {
gte: createdAt.min,
lte: createdAt.max,
},
response: {
is: {
id: {
in: mockResponseIds,
},
},
},
},
});
});
test("returns 0 without querying when responseIds filter is empty", async () => {
const result = await getDisplayCountBySurveyId(mockSurveyId, { responseIds: [] });
expect(result).toBe(0);
expect(prisma.display.count).not.toHaveBeenCalled();
});
});
describe("Sad Path", () => {
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.display.count).mockRejectedValue(errToThrow);
await expect(getDisplayCountBySurveyId(mockSurveyId)).rejects.toThrow(DatabaseError);
});
});
});
describe("getDisplaysByContactId", () => {
describe("Happy Path", () => {
test("returns displays for a contact ordered by createdAt desc", async () => {
+1 -127
View File
@@ -1,11 +1,6 @@
import dns from "node:dns";
import type { Agent } from "undici";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
createPinnedDispatcher,
validateAndResolveWebhookUrl,
validateWebhookUrl,
} from "./validate-webhook-url";
import { validateWebhookUrl } from "./validate-webhook-url";
vi.mock("node:dns", () => ({
default: {
@@ -377,125 +372,4 @@ describe("validateWebhookUrl", () => {
);
});
});
describe("validateAndResolveWebhookUrl returns pinned address", () => {
test("returns IPv4 literal as { ip, family: 4 }", async () => {
await expect(validateAndResolveWebhookUrl("https://93.184.216.34/webhook")).resolves.toEqual({
ip: "93.184.216.34",
family: 4,
});
});
test("returns IPv6 literal stripped of brackets as { ip, family: 6 }", async () => {
await expect(
validateAndResolveWebhookUrl("https://[2606:2800:220:1:248:1893:25c8:1946]/webhook")
).resolves.toEqual({
ip: "2606:2800:220:1:248:1893:25c8:1946",
family: 6,
});
});
test("returns first resolved IPv4 for hostnames", async () => {
setupDnsResolution(["93.184.216.34", "23.23.23.23"]);
await expect(validateAndResolveWebhookUrl("https://example.com/webhook")).resolves.toEqual({
ip: "93.184.216.34",
family: 4,
});
});
test("returns IPv6 when only IPv6 is resolvable", async () => {
setupDnsResolution(null, ["2606:2800:220:1:248:1893:25c8:1946"]);
await expect(validateAndResolveWebhookUrl("https://example.com/webhook")).resolves.toEqual({
ip: "2606:2800:220:1:248:1893:25c8:1946",
family: 6,
});
});
test("returns null for blocked hostname when DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS is enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateAndResolveWebhookUrl: fn } = await import("./validate-webhook-url");
await expect(fn("http://localhost/webhook")).resolves.toBeNull();
});
});
describe("createPinnedDispatcher", () => {
test("returns an undici Agent instance", async () => {
const { Agent } = await import("undici");
const dispatcher = createPinnedDispatcher({ ip: "93.184.216.34", family: 4 });
expect(dispatcher).toBeInstanceOf(Agent);
await dispatcher.close();
});
// Reach into the Agent's connect options to grab the lookup function we
// installed. This is implementation-coupled but the only way to assert the
// pinning behavior without spinning up a real socket. If undici changes
// internals and this stops finding the lookup, the integration-style test
// below still verifies the end-to-end behavior.
const extractLookup = (
agent: Agent
):
| ((
host: string,
opts: { all?: boolean },
cb: (
err: NodeJS.ErrnoException | null,
address: string | { address: string; family: number }[],
family?: number
) => void
) => void)
| undefined => {
const symbols = Object.getOwnPropertySymbols(agent);
for (const sym of symbols) {
const value = (agent as unknown as Record<symbol, unknown>)[sym];
if (value && typeof value === "object" && "connect" in value) {
const connect = (value as { connect?: { lookup?: unknown } }).connect;
if (connect && typeof connect.lookup === "function") {
return connect.lookup as never;
}
}
}
return undefined;
};
test("lookup returns the pinned IP regardless of which hostname is queried (all=true)", async () => {
const dispatcher = createPinnedDispatcher({ ip: "93.184.216.34", family: 4 });
const lookup = extractLookup(dispatcher);
// If we couldn't reach into the Agent, skip the deep assertion — the
// integration test still covers the contract.
if (!lookup) {
await dispatcher.close();
return;
}
const result = await new Promise<{ address: string; family: number }[]>((resolve, reject) => {
lookup("attacker-rebound.example.com", { all: true }, (err, addresses) => {
if (err) reject(err);
else resolve(addresses as { address: string; family: number }[]);
});
});
expect(result).toEqual([{ address: "93.184.216.34", family: 4 }]);
await dispatcher.close();
});
test("lookup honours legacy (err, address, family) form when all is not set", async () => {
const dispatcher = createPinnedDispatcher({ ip: "2606:4700::1", family: 6 });
const lookup = extractLookup(dispatcher);
if (!lookup) {
await dispatcher.close();
return;
}
const result = await new Promise<{ address: string; family: number }>((resolve, reject) => {
lookup("anything.example", {}, (err, address, family) => {
if (err) reject(err);
else resolve({ address: address as string, family: family ?? -1 });
});
});
expect(result).toEqual({ address: "2606:4700::1", family: 6 });
await dispatcher.close();
});
});
});
@@ -1,67 +0,0 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { createPinnedDispatcher } from "./validate-webhook-url";
// Real DNS, no node:dns mock. The whole point of this file is to prove that
// the pinned dispatcher bypasses DNS entirely — so the hostname we use must
// genuinely fail to resolve in real life.
vi.unmock("node:dns");
vi.mock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
}));
describe("DNS rebinding TOCTOU — pinned dispatcher", () => {
let server: http.Server;
let port: number;
const visited: string[] = [];
beforeAll(async () => {
server = http.createServer((req, res) => {
visited.push(req.headers.host ?? "");
res.writeHead(200, { "content-type": "text/plain" });
res.end("hit-pinned-target");
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
port = (server.address() as AddressInfo).port;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
test("baseline: fetch to *.invalid hostname fails (real DNS cannot resolve it)", async () => {
// RFC 2606 reserves the .invalid TLD — guaranteed to never resolve.
// This proves DNS is what fetch normally relies on.
await expect(
fetch(`http://attacker-rebind.invalid:${port}/`).catch((e: Error) => {
throw new Error(e.message);
})
).rejects.toThrow(/fetch failed/i);
});
test("with pinned dispatcher: connection lands on pinned IP even though hostname is unresolvable", async () => {
// Simulates: validate resolved attacker.com to a public IP (here represented
// by 127.0.0.1 — the local test server). Attacker then rebinds DNS so a
// second lookup would return something different (or nothing). The pinned
// dispatcher means there *is* no second lookup — undici uses our IP.
const dispatcher = createPinnedDispatcher({ ip: "127.0.0.1", family: 4 });
try {
const response = await fetch(`http://attacker-rebind.invalid:${port}/`, {
// RequestInit doesn't type `dispatcher` — undici accepts it at runtime.
dispatcher,
} as RequestInit & { dispatcher: typeof dispatcher });
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe("hit-pinned-target");
// The Host header preserves the original hostname (TLS SNI parity);
// only the TCP target was rerouted via the pin.
expect(visited.at(-1)).toContain("attacker-rebind.invalid");
} finally {
await dispatcher.close();
}
});
});
+47 -108
View File
@@ -1,6 +1,5 @@
import "server-only";
import dns from "node:dns";
import { Agent } from "undici";
import { InvalidInputError } from "@formbricks/types/errors";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
@@ -68,15 +67,13 @@ const isPrivateIPv6 = (ip: string): boolean => {
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
};
const isPrivateIP = (ip: string, family: 4 | 6): boolean => {
return family === 4 ? isPrivateIPv4(ip) : isPrivateIPv6(ip);
const isPrivateIP = (ip: string): boolean => {
return isPrivateIPv4(ip) || isPrivateIPv6(ip);
};
const DNS_TIMEOUT_MS = 3000;
export type ResolvedAddress = { ip: string; family: 4 | 6 };
const resolveHostnameToAddresses = (hostname: string): Promise<ResolvedAddress[]> => {
const resolveHostnameToIPs = (hostname: string): Promise<string[]> => {
return new Promise((resolve, reject) => {
let settled = false;
@@ -92,16 +89,16 @@ const resolveHostnameToAddresses = (hostname: string): Promise<ResolvedAddress[]
}, DNS_TIMEOUT_MS);
dns.resolve(hostname, (errV4, ipv4Addresses) => {
const ipv4: ResolvedAddress[] = errV4 ? [] : ipv4Addresses.map((ip) => ({ ip, family: 4 as const }));
const ipv4 = errV4 ? [] : ipv4Addresses;
dns.resolve6(hostname, (errV6, ipv6Addresses) => {
const ipv6: ResolvedAddress[] = errV6 ? [] : ipv6Addresses.map((ip) => ({ ip, family: 6 as const }));
const all = [...ipv4, ...ipv6];
const ipv6 = errV6 ? [] : ipv6Addresses;
const allAddresses = [...ipv4, ...ipv6];
if (all.length === 0) {
if (allAddresses.length === 0) {
settle(reject, new Error(`DNS resolution failed for hostname: ${hostname}`));
} else {
settle(resolve, all);
settle(resolve, allAddresses);
}
});
});
@@ -117,35 +114,59 @@ const stripIPv6Brackets = (hostname: string): string => {
const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/;
const parseWebhookUrl = (url: string): URL => {
/**
* Validates a webhook URL to prevent Server-Side Request Forgery (SSRF).
*
* Checks performed:
* 1. URL must be well-formed
* 2. Protocol must be HTTPS or HTTP
* 3. Hostname must not be a known internal name (localhost, metadata endpoints)
* 4. IP literal hostnames are checked directly against private ranges
* 5. Domain hostnames are resolved via DNS; all resulting IPs must be public
*
* @throws {InvalidInputError} when the URL fails any validation check
*/
export const validateWebhookUrl = async (url: string): Promise<void> => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new InvalidInputError("Invalid webhook URL format");
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new InvalidInputError("Webhook URL must use HTTPS or HTTP protocol");
}
return parsed;
};
const validateIpLiteral = (hostname: string): ResolvedAddress | null => {
const hostname = parsed.hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
}
// Direct IP literal — validate without DNS resolution
const isIPv4Literal = IPV4_LITERAL.test(hostname);
const isIPv6Literal = hostname.startsWith("[");
if (!isIPv4Literal && !isIPv6Literal) return null;
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
const family: 4 | 6 = isIPv4Literal ? 4 : 6;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip, family)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
}
return { ip, family };
};
const resolveHostnameOrThrow = async (hostname: string): Promise<ResolvedAddress[]> => {
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
return;
}
// Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[];
try {
return await resolveHostnameToAddresses(hostname);
resolvedIPs = await resolveHostnameToIPs(hostname);
} catch (error) {
const isTimeout = error instanceof Error && error.message.includes("timed out");
throw new InvalidInputError(
@@ -154,94 +175,12 @@ const resolveHostnameOrThrow = async (hostname: string): Promise<ResolvedAddress
: `Could not resolve webhook URL hostname: ${hostname}`
);
}
};
/**
* Validates a webhook URL and returns a resolved address pinned for delivery.
*
* Returns the IP literal or first DNS-resolved address. Returns `null` only when
* `DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS` is enabled for a known internal hostname
* (localhost etc.) — in that case the caller skips IP pinning so /etc/hosts works.
*
* Pinning the returned address into the fetch dispatcher closes the TOCTOU window
* where DNS could rebind between this validation and the subsequent HTTP request.
*
* @throws {InvalidInputError} when the URL fails any validation check
*/
export const validateAndResolveWebhookUrl = async (url: string): Promise<ResolvedAddress | null> => {
const parsed = parseWebhookUrl(url);
const hostname = parsed.hostname;
const isBlockedName = BLOCKED_HOSTNAMES.has(hostname.toLowerCase());
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isBlockedName) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
const literal = validateIpLiteral(hostname);
if (literal) return literal;
// Skip DNS for localhost-like hostnames when internal URLs are allowed (resolved via /etc/hosts)
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isBlockedName) {
return null;
}
const resolved = await resolveHostnameOrThrow(hostname);
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
for (const addr of resolved) {
if (isPrivateIP(addr.ip, addr.family)) {
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
}
// Pin to the first resolved address. All addresses already passed the public-IP
// check above, so any choice is safe.
return resolved[0];
};
/**
* Validates a webhook URL to prevent Server-Side Request Forgery (SSRF).
* Thin wrapper around {@link validateAndResolveWebhookUrl} for callers that only
* need validation (e.g. webhook create/update) and discard the resolved address.
*
* @throws {InvalidInputError} when the URL fails any validation check
*/
export const validateWebhookUrl = async (url: string): Promise<void> => {
await validateAndResolveWebhookUrl(url);
};
/**
* Builds an undici Agent that pins outgoing TCP connections to the given IP/family,
* regardless of what hostname the URL resolves to at fetch time. Use the address
* returned by {@link validateAndResolveWebhookUrl} so the IP that was validated is
* the IP that gets connected to — closes the DNS-rebinding TOCTOU window.
*
* TLS SNI/cert validation still uses the original hostname from the URL.
*/
export const createPinnedDispatcher = (address: ResolvedAddress): Agent => {
return new Agent({
connect: {
// undici calls `lookup(host, { all: true, ... }, cb)`, so honor both forms:
// when `all` is true we must return an array; otherwise the legacy
// (err, address, family) signature. Returning the wrong form yields
// "Invalid IP address: undefined" at connect time.
lookup: (_hostname, options, callback) => {
if (options && typeof options === "object" && (options as { all?: boolean }).all) {
(
callback as (
err: NodeJS.ErrnoException | null,
addresses: { address: string; family: number }[]
) => void
)(null, [{ address: address.ip, family: address.family }]);
return;
}
(callback as (err: NodeJS.ErrnoException | null, address: string, family: number) => void)(
null,
address.ip,
address.family
);
},
},
});
};
@@ -1,5 +1,5 @@
import { ReactNode } from "react";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -52,7 +52,7 @@ export const ContactsPageLayout = async ({
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/billing`
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
@@ -29,7 +29,6 @@ interface QuotasCardProps {
isFormbricksCloud?: boolean;
quotas: TSurveyQuota[];
hasResponses: boolean;
enterpriseLicenseRequestFormUrl: string;
}
const AddQuotaButton = ({
@@ -68,7 +67,6 @@ export const QuotasCard = ({
isFormbricksCloud,
quotas,
hasResponses,
enterpriseLicenseRequestFormUrl,
}: QuotasCardProps) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -179,7 +177,7 @@ export const QuotasCard = ({
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: enterpriseLicenseRequestFormUrl,
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
@@ -1,7 +1,7 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { TeamsTable } from "@/modules/ee/teams/team-list/components/teams-table";
import { getProjectsByOrganizationId } from "@/modules/ee/teams/team-list/lib/project";
@@ -41,7 +41,7 @@ export const TeamsView = async ({
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/billing`
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
: "https://formbricks.com/docs/self-hosting/license#30-day-trial-license-request",
},
{
text: t("common.learn_more"),
@@ -36,7 +36,6 @@ interface EmailCustomizationSettingsProps {
user: TUser | null;
fbLogoUrl: string;
isStorageConfigured: boolean;
enterpriseLicenseRequestFormUrl: string;
}
export const EmailCustomizationSettings = ({
@@ -48,7 +47,6 @@ export const EmailCustomizationSettings = ({
user,
fbLogoUrl,
isStorageConfigured,
enterpriseLicenseRequestFormUrl,
}: EmailCustomizationSettingsProps) => {
const { t } = useTranslation();
@@ -186,7 +184,7 @@ export const EmailCustomizationSettings = ({
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: enterpriseLicenseRequestFormUrl,
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
@@ -1,6 +1,6 @@
import { Project } from "@prisma/client";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { EditBranding } from "@/modules/ee/whitelabel/remove-branding/components/edit-branding";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
@@ -26,7 +26,7 @@ export const BrandingSettingsCard = async ({
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/billing`
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
@@ -1,11 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import {
createPinnedDispatcher,
validateAndResolveWebhookUrl,
validateWebhookUrl,
} from "@/lib/utils/validate-webhook-url";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { getTranslate } from "@/lingodotdev/server";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { testEndpoint } from "./webhook";
@@ -36,12 +32,6 @@ vi.mock("@/lib/crypto", () => ({
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn(async () => undefined),
validateAndResolveWebhookUrl: vi.fn(async () => ({ ip: "93.184.216.34", family: 4 })),
createPinnedDispatcher: vi.fn(() => ({
__pinned: true,
close: vi.fn(async () => undefined),
destroy: vi.fn(async () => undefined),
})),
}));
vi.mock("@/lingodotdev/server", () => ({
@@ -62,12 +52,6 @@ describe("testEndpoint", () => {
constantsMock.dangerouslyAllow = false;
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
vi.mocked(validateAndResolveWebhookUrl).mockResolvedValue({ ip: "93.184.216.34", family: 4 });
vi.mocked(createPinnedDispatcher).mockReturnValue({
__pinned: true,
close: vi.fn(async () => undefined),
destroy: vi.fn(async () => undefined),
} as never);
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
vi.mocked(isDiscordWebhook).mockReturnValue(false);
});
@@ -96,7 +80,7 @@ describe("testEndpoint", () => {
new InvalidInputError(messageKey)
);
expect(validateAndResolveWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(generateStandardWebhookSignature).toHaveBeenCalled();
expect(getTranslate).toHaveBeenCalled();
});
@@ -12,11 +12,7 @@ import {
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import {
createPinnedDispatcher,
validateAndResolveWebhookUrl,
validateWebhookUrl,
} from "@/lib/utils/validate-webhook-url";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { getTranslate } from "@/lingodotdev/server";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { TWebhookInput } from "../types/webhooks";
@@ -167,7 +163,7 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
};
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
const address = await validateAndResolveWebhookUrl(url);
await validateWebhookUrl(url);
if (isDiscordWebhook(url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
@@ -175,10 +171,6 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
// Hoisted out of the try so the finally can close it on every path.
// Pin TCP connect to the validated IP — closes DNS-rebinding TOCTOU between
// validation and fetch (undici otherwise resolves the hostname a second time).
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
try {
const webhookMessageId = uuidv7();
@@ -211,8 +203,7 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
headers: requestHeaders,
signal: controller.signal,
redirect: redirectMode,
dispatcher,
} as RequestInit & { dispatcher?: ReturnType<typeof createPinnedDispatcher> });
});
const statusCode = response.status;
@@ -245,9 +236,5 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
);
} finally {
clearTimeout(timeout);
// destroy() — not close() — force-kills sockets. close() drains gracefully and
// would deadlock if the endpoint accepted TCP but never responded (controller.abort()
// above cancels fetch, but destroy is the belt-and-suspenders cleanup).
await dispatcher?.destroy();
}
};
@@ -39,7 +39,6 @@ interface OrganizationActionsProps {
isStorageConfigured: boolean;
isTeamAdmin: boolean;
userAdminTeamIds?: string[];
enterpriseLicenseRequestFormUrl: string;
}
export const OrganizationActions = ({
@@ -57,7 +56,6 @@ export const OrganizationActions = ({
isStorageConfigured,
isTeamAdmin,
userAdminTeamIds,
enterpriseLicenseRequestFormUrl,
}: OrganizationActionsProps) => {
const router = useRouter();
const { t } = useTranslation();
@@ -176,7 +174,6 @@ export const OrganizationActions = ({
isOwnerOrManager={isOwnerOrManager}
isTeamAdmin={isTeamAdmin}
userAdminTeamIds={userAdminTeamIds}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
/>
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setIsLeaveOrganizationModalOpen}>
@@ -29,7 +29,6 @@ interface IndividualInviteTabProps {
environmentId: string;
membershipRole?: TOrganizationRole;
showTeamAdminRestrictions: boolean;
enterpriseLicenseRequestFormUrl: string;
}
export const IndividualInviteTab = ({
@@ -41,7 +40,6 @@ export const IndividualInviteTab = ({
environmentId,
membershipRole,
showTeamAdminRestrictions,
enterpriseLicenseRequestFormUrl,
}: IndividualInviteTabProps) => {
const ZFormSchema = z.object({
name: ZUserName,
@@ -193,7 +191,7 @@ export const IndividualInviteTab = ({
href={
isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: enterpriseLicenseRequestFormUrl
: "https://formbricks.com/upgrade-self-hosting-license"
}>
{t("common.upgrade_plan")}
</Link>
@@ -30,7 +30,6 @@ interface InviteMemberModalProps {
isOwnerOrManager: boolean;
isTeamAdmin: boolean;
userAdminTeamIds?: string[];
enterpriseLicenseRequestFormUrl: string;
}
export const InviteMemberModal = ({
@@ -46,7 +45,6 @@ export const InviteMemberModal = ({
isOwnerOrManager,
isTeamAdmin,
userAdminTeamIds,
enterpriseLicenseRequestFormUrl,
}: InviteMemberModalProps) => {
const [type, setType] = useState<"individual" | "bulk">("individual");
@@ -70,7 +68,6 @@ export const InviteMemberModal = ({
teams={filteredTeams}
membershipRole={membershipRole}
showTeamAdminRestrictions={showTeamAdminRestrictions}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
/>
),
bulk: (
@@ -2,12 +2,7 @@ import { Suspense } from "react";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import {
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
INVITE_DISABLED,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
} from "@/lib/constants";
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
@@ -75,7 +70,6 @@ export const MembersView = async ({
isAccessControlAllowed={isAccessControlAllowed}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isStorageConfigured={IS_STORAGE_CONFIGURED}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
environmentId={environmentId}
isMultiOrgEnabled={isMultiOrgEnabled}
teams={teams}
@@ -29,7 +29,6 @@ interface SettingsViewProps {
isFormbricksCloud: boolean;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
enterpriseLicenseRequestFormUrl: string;
}
export const SettingsView = ({
@@ -47,7 +46,6 @@ export const SettingsView = ({
projectPermission,
isFormbricksCloud,
quotas,
enterpriseLicenseRequestFormUrl,
}: SettingsViewProps) => {
const isAppSurvey = localSurvey.type === "app";
@@ -72,11 +70,7 @@ export const SettingsView = ({
</div>
</div>
) : (
<TargetingLockedCard
isFormbricksCloud={isFormbricksCloud}
environmentId={environment.id}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
/>
<TargetingLockedCard isFormbricksCloud={isFormbricksCloud} environmentId={environment.id} />
)}
</div>
) : null}
@@ -95,7 +89,6 @@ export const SettingsView = ({
isFormbricksCloud={isFormbricksCloud}
quotas={quotas}
hasResponses={responseCount > 0}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
/>
<ResponseOptionsCard
@@ -51,7 +51,6 @@ interface SurveyEditorProps {
quotas: TSurveyQuota[];
isExternalUrlsAllowed: boolean;
publicDomain: string;
enterpriseLicenseRequestFormUrl: string;
}
export const SurveyEditor = ({
@@ -81,7 +80,6 @@ export const SurveyEditor = ({
quotas,
isExternalUrlsAllowed,
publicDomain,
enterpriseLicenseRequestFormUrl,
}: SurveyEditorProps) => {
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("elements");
const [activeElementId, setActiveElementId] = useState<string | null>(null);
@@ -268,7 +266,6 @@ export const SurveyEditor = ({
isFormbricksCloud={isFormbricksCloud}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
/>
)}
@@ -283,7 +280,6 @@ export const SurveyEditor = ({
userEmail={userEmail}
teamMemberDetails={teamMemberDetails}
locale={locale}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
/>
)}
</main>
@@ -9,14 +9,9 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
interface TargetingLockedCardProps {
isFormbricksCloud: boolean;
environmentId: string;
enterpriseLicenseRequestFormUrl: string;
}
export const TargetingLockedCard = ({
isFormbricksCloud,
environmentId,
enterpriseLicenseRequestFormUrl,
}: TargetingLockedCardProps) => {
export const TargetingLockedCard = ({ isFormbricksCloud, environmentId }: TargetingLockedCardProps) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -52,7 +47,7 @@ export const TargetingLockedCard = ({
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: enterpriseLicenseRequestFormUrl,
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
-2
View File
@@ -1,7 +1,6 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
DEFAULT_LOCALE,
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
IS_FORMBRICKS_CLOUD,
IS_STORAGE_CONFIGURED,
MAIL_FROM,
@@ -139,7 +138,6 @@ export const SurveyEditorPage = async (props: {
quotas={quotas}
isExternalUrlsAllowed={isExternalUrlsAllowed}
publicDomain={publicDomain}
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
/>
);
};
@@ -22,7 +22,6 @@ interface FollowUpsViewProps {
userEmail: string;
teamMemberDetails: TFollowUpEmailToUser[];
locale: TUserLocale;
enterpriseLicenseRequestFormUrl: string;
}
export const FollowUpsView = ({
@@ -35,7 +34,6 @@ export const FollowUpsView = ({
userEmail,
teamMemberDetails,
locale,
enterpriseLicenseRequestFormUrl,
}: FollowUpsViewProps) => {
const { t } = useTranslation();
const [addFollowUpModalOpen, setAddFollowUpModalOpen] = useState(false);
@@ -56,7 +54,7 @@ export const FollowUpsView = ({
: t("common.request_trial_license"),
href: isFormbricksCloud
? `/environments/${localSurvey.environmentId}/settings/billing`
: enterpriseLicenseRequestFormUrl,
: "https://formbricks.com/docs/self-hosting/license",
},
{
text: t("common.learn_more"),
+16 -16
View File
@@ -45,14 +45,14 @@
"@lexical/rich-text": "0.41.0",
"@lexical/table": "0.41.0",
"@next-auth/prisma-adapter": "1.0.7",
"@opentelemetry/auto-instrumentations-node": "0.75.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.217.0",
"@opentelemetry/exporter-prometheus": "0.217.0",
"@opentelemetry/exporter-trace-otlp-http": "0.217.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-metrics": "2.7.1",
"@opentelemetry/sdk-node": "0.217.0",
"@opentelemetry/sdk-trace-base": "2.7.1",
"@opentelemetry/auto-instrumentations-node": "0.71.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
"@opentelemetry/exporter-prometheus": "0.213.0",
"@opentelemetry/exporter-trace-otlp-http": "0.213.0",
"@opentelemetry/resources": "2.6.1",
"@opentelemetry/sdk-metrics": "2.6.1",
"@opentelemetry/sdk-node": "0.213.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/semantic-conventions": "1.40.0",
"@paralleldrive/cuid2": "2.3.1",
"@prisma/client": "6.19.3",
@@ -96,7 +96,7 @@
"lexical": "0.41.0",
"lucide-react": "0.577.0",
"markdown-it": "14.1.1",
"next": "16.2.6",
"next": "16.2.4",
"next-auth": "4.24.13",
"next-safe-action": "8.1.10",
"nodemailer": "8.0.7",
@@ -107,12 +107,12 @@
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",
"react": "19.2.6",
"react": "19.2.5",
"react-calendar": "6.0.1",
"react-colorful": "5.6.2",
"react-colorful": "5.6.1",
"react-confetti": "6.4.0",
"react-day-picker": "9.14.0",
"react-dom": "19.2.6",
"react-dom": "19.2.5",
"react-hook-form": "7.71.2",
"react-hot-toast": "2.6.0",
"react-i18next": "16.5.8",
@@ -142,15 +142,15 @@
"@types/papaparse": "5.5.2",
"@types/qrcode": "1.5.6",
"@types/sanitize-html": "2.16.1",
"@vitest/coverage-v8": "4.1.6",
"@vitest/coverage-v8": "4.1.5",
"autoprefixer": "10.4.27",
"cross-env": "10.1.0",
"dotenv": "17.3.1",
"postcss": "8.5.14",
"postcss": "8.5.12",
"resize-observer-polyfill": "1.5.1",
"vite": "7.3.3",
"vite": "7.3.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.6",
"vitest": "4.1.5",
"vitest-mock-extended": "3.1.1"
}
}
+58 -25
View File
@@ -170,10 +170,6 @@ install_formbricks() {
echo "🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your $ubuntu_version server."
echo ""
# Remove any old Docker installations, without stopping the script if they're not found
echo "🧹 Time to sweep away any old Docker installations."
sudo apt-get remove docker docker-engine docker.io containerd runc >/dev/null 2>&1 || true
# Update package list
echo "🔄 Updating your package list."
sudo apt-get update >/dev/null 2>&1
@@ -183,32 +179,69 @@ install_formbricks() {
sudo apt-get install -y \
ca-certificates \
curl \
gnupg \
lsb-release >/dev/null 2>&1
# Set up Docker's official GPG key & stable repository
echo "🔑 Adding Docker's official GPG key and setting up the stable repository."
sudo mkdir -m 0755 -p /etc/apt/keyrings >/dev/null 2>&1
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg >/dev/null 2>&1
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null 2>&1
# Reuse an existing Docker installation instead of replacing it implicitly.
if command -v docker >/dev/null 2>&1; then
echo "✅ Docker is already installed."
# Update package list again
echo "🔄 Updating your package list again."
sudo apt-get update >/dev/null 2>&1
if docker info >/dev/null 2>&1 || sudo docker info >/dev/null 2>&1; then
echo "✅ Docker daemon is reachable. Reusing the existing Docker installation."
# Install Docker
echo "🐳 Installing Docker."
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1
# Test Docker installation
echo "🚀 Testing your Docker installation."
if docker --version >/dev/null 2>&1; then
echo "🎉 Docker is installed!"
if docker compose version >/dev/null 2>&1 || sudo docker compose version >/dev/null 2>&1; then
echo "✅ Docker Compose is available."
else
echo "❌ Docker Compose is not available on this system."
echo "Please install Docker Compose or upgrade Docker so 'docker compose' works, then rerun this script."
exit 1
fi
else
echo "❌ Docker is installed, but the daemon is not reachable."
echo "Please start or fix Docker and rerun this script."
echo "To avoid modifying an existing Docker setup without your consent, this script will not remove or reinstall Docker automatically."
exit 1
fi
else
echo "❌ Docker is not installed. Please install Docker before proceeding."
exit 1
# Remove old Docker packages only when Docker is not installed at all.
echo "⚠️ Legacy Docker-related packages may conflict with Docker CE."
echo "These packages can also be used outside Docker, so they will only be removed with your consent."
read -p "Remove legacy packages (docker/docker-engine/docker.io/containerd/runc)? [y/N] " remove_legacy_docker_pkgs
remove_legacy_docker_pkgs=$(echo "$remove_legacy_docker_pkgs" | tr '[:upper:]' '[:lower:]')
if [[ "$remove_legacy_docker_pkgs" == "y" || "$remove_legacy_docker_pkgs" == "yes" ]]; then
echo "🧹 Removing old Docker installations."
sudo apt-get remove docker docker-engine docker.io containerd runc >/dev/null 2>&1 || true
else
echo "⏭️ Skipping legacy package removal."
echo "If Docker installation fails due to conflicting packages, rerun the script and allow the removal step."
fi
echo "📦 Installing Docker-specific dependencies."
sudo apt-get install -y gnupg >/dev/null 2>&1
# Set up Docker's official GPG key & stable repository.
echo "🔑 Adding Docker's official GPG key and setting up the stable repository."
sudo mkdir -m 0755 -p /etc/apt/keyrings >/dev/null 2>&1
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg >/dev/null 2>&1
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null 2>&1
# Update package list again after adding the Docker repository.
echo "🔄 Updating your package list again."
sudo apt-get update >/dev/null 2>&1
# Install Docker only when it is not already present on the system.
echo "🐳 Installing Docker."
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1
# Test Docker installation.
echo "🚀 Testing your Docker installation."
if docker --version >/dev/null 2>&1; then
echo "🎉 Docker is installed!"
else
echo "❌ Docker is not installed. Please install Docker before proceeding."
exit 1
fi
fi
# Adding your user to the Docker group
-1
View File
@@ -96,7 +96,6 @@
"xm-and-surveys/surveys/link-surveys/data-prefilling",
"xm-and-surveys/surveys/link-surveys/embed-surveys",
"xm-and-surveys/surveys/link-surveys/link-settings",
"xm-and-surveys/surveys/link-surveys/pretty-url",
"xm-and-surveys/surveys/link-surveys/personal-links",
"xm-and-surveys/surveys/link-surveys/single-use-links",
"xm-and-surveys/surveys/link-surveys/source-tracking",
+3 -3
View File
@@ -8,7 +8,7 @@ The Formbricks core source code is licensed under AGPLv3 and available on GitHub
<Note>
Want to get your hands on the Enterprise Edition? [Request a free Enterprise Edition
Trial](https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=docs) License to build a fully functioning Proof of
Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of
Concept.
</Note>
@@ -38,11 +38,11 @@ The Formbricks core application is licensed under the [AGPLv3 Open Source Licens
### The Enterprise Edition
Additional to the AGPL licensed Formbricks core, this repository contains code licensed under an Enterprise license. The [code](https://github.com/formbricks/formbricks/tree/main/apps/web/modules/ee) and [license](https://github.com/formbricks/formbricks/blob/main/apps/web/modules/ee/LICENSE) for the enterprise functionality can be found in the `/apps/web/modules/ee` folder of this repository. This additional functionality is not part of the AGPLv3 licensed Formbricks core and is designed to meet the needs of larger teams and enterprises. This advanced functionality is already included in the Docker images, but you need an [Enterprise License Key](https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=docs) to unlock it.
Additional to the AGPL licensed Formbricks core, this repository contains code licensed under an Enterprise license. The [code](https://github.com/formbricks/formbricks/tree/main/apps/web/modules/ee) and [license](https://github.com/formbricks/formbricks/blob/main/apps/web/modules/ee/LICENSE) for the enterprise functionality can be found in the `/apps/web/modules/ee` folder of this repository. This additional functionality is not part of the AGPLv3 licensed Formbricks core and is designed to meet the needs of larger teams and enterprises. This advanced functionality is already included in the Docker images, but you need an [Enterprise License Key](https://formbricks.com/enterprise-license?source=docs) to unlock it.
<Note>
Want to get your hands on the Enterprise Edition? [Request a free Enterprise Edition
Trial](https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=docs) License to build a fully functioning Proof of
Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of
Concept.
</Note>
@@ -1,81 +0,0 @@
---
title: "Pretty URL"
description: "Create a custom, memorable URL for your survey instead of sharing a long auto-generated link."
icon: "link"
---
<Note>
**Self-Hosted Only**: Pretty URLs are available exclusively on self-hosted Formbricks instances. This feature is not available on Formbricks Cloud.
</Note>
## What is a Pretty URL?
By default, every survey is accessible at a URL containing its auto-generated ID, e.g. `yourdomain.com/s/cm1abc123xyz`. A Pretty URL lets you replace that with a short, human-readable slug of your choice:
```
yourdomain.com/p/customer-feedback
```
When someone visits the pretty URL, they are automatically redirected to the actual survey. Query parameters such as `suId` and `lang` are forwarded as well.
## Setting Up a Pretty URL
<Steps>
<Step title="Open the Share Modal">
Navigate to your survey's **Summary** page and click the **Share survey** button in the top toolbar.
</Step>
<Step title="Go to the Pretty URL tab">
In the Share Modal, select the **Pretty URL** tab.
</Step>
<Step title="Enter a slug">
Type your desired slug in the input field. Slugs may only contain **lowercase letters, numbers, and hyphens** (e.g. `customer-feedback`, `q4-nps-2024`).
The full URL is shown in real time below the input so you can confirm how it will look.
</Step>
<Step title="Save">
Click **Save**. The slug is now live. Anyone visiting the pretty URL is immediately redirected to your survey.
</Step>
</Steps>
## Managing Pretty URLs
Once a slug is saved, the Pretty URL tab shows the active link with two actions:
- **Copy**: copies the full pretty URL to your clipboard.
- **Remove**: deletes the slug (after a confirmation prompt). The survey remains accessible via its original `/s/[surveyId]` URL.
## Viewing All Pretty URLs in Your Organization
All surveys that have a pretty URL assigned are listed in one place:
1. Go to **Organization Settings → Domain**.
2. Open the **Pretty URLs** section.
The table shows each survey's name, workspace, slug, and environment type (production / development).
## Slug Rules
| Rule | Detail |
|------|--------|
| Characters | Lowercase letters (a-z), digits (0-9), and hyphens (-) |
| Uniqueness | Must be unique across your entire Formbricks instance |
| Format example | `customer-feedback`, `onboarding-survey`, `q4-nps` |
## Query Parameter Forwarding
Pretty URLs forward all query parameters to the destination survey URL. For example:
```
/p/customer-feedback?suId=contact123&lang=de
```
redirects to:
```
/s/[surveyId]?suId=contact123&lang=de
```
This means features like [single-use links](/xm-and-surveys/surveys/link-surveys/single-use-links), [data prefilling](/xm-and-surveys/surveys/link-surveys/data-prefilling), and [multi-language surveys](/xm-and-surveys/surveys/general-features/multi-language-surveys) all work with pretty URLs.
+7 -13
View File
@@ -46,8 +46,8 @@
"dev:setup": "bash scripts/setup-dev-env.sh"
},
"dependencies": {
"react": "19.2.6",
"react-dom": "19.2.6"
"react": "19.2.5",
"react-dom": "19.2.5"
},
"devDependencies": {
"@azure/playwright": "1.1.5",
@@ -84,26 +84,20 @@
"pnpm": {
"overrides": {
"@hono/node-server": "1.19.13",
"@protobufjs/utf8": "1.1.1",
"@tootallnate/once": "3.0.1",
"@xmldom/xmldom": "0.9.10",
"ajv@6": "6.14.0",
"axios": "1.15.2",
"effect": "3.20.0",
"fast-uri": "3.1.2",
"fast-xml-parser": "5.7.0",
"hono": "4.12.18",
"ip-address": "10.1.1",
"fast-xml-parser": "5.5.7",
"hono": "4.12.14",
"lodash": "4.18.1",
"node-forge": "1.4.0",
"postcss": "8.5.14",
"protobufjs@7": "7.5.8",
"protobufjs@8": "8.2.0",
"tar": "7.5.15",
"uuid@11": "11.1.1"
"@opentelemetry/otlp-transformer>protobufjs": "8.0.1",
"tar": "7.5.13"
},
"comments": {
"overrides": "Security fixes for transitive dependencies that still fail a no-override audit. Remove each override when its upstream chain adopts a patched version: @hono/node-server/hono via Prisma dev tooling | @protobufjs/utf8 (CVE overlong UTF-8) - awaiting @opentelemetry/otlp-transformer update | @tootallnate/once and tar via sqlite3/node-gyp chain | @xmldom/xmldom (XML injection/DoS CVEs) - awaiting @boxyhq/saml20 to pin to >=0.9.10 | axios, lodash, and node-forge via @boxyhq/saml-jackson | ajv@6 via webpack/eslint | effect (GHSA-38f7-945m-qr2g) - awaiting @prisma/config update | fast-uri (CVE-2025-48944/48945) - awaiting ajv/schema-utils update | fast-xml-parser via AWS SDK XML builder | ip-address (XSS in Address6) - awaiting mongodb/socks update | postcss (CVE-2025-62695) - awaiting next.js to unpin postcss | protobufjs@7/8 (GHSA-xq3m-2v4x-88gg et al.) - awaiting @grpc/proto-loader/otlp-transformer update | uuid@11 (CVE-2025-61475) - awaiting typeorm update"
"overrides": "Security fixes for transitive dependencies that still fail a no-override audit. Remove each override when its upstream chain adopts a patched version: @hono/node-server/hono/effect via Prisma dev tooling | @tootallnate/once and tar via sqlite3/BoxyHQ SAML Jackson database tooling | @xmldom/xmldom, axios, lodash, and node-forge via @boxyhq/saml-jackson | ajv via @vercel/style-guide/eslint-plugin-tsdoc | protobufjs via BoxyHQ/OpenTelemetry metrics | fast-xml-parser via AWS SDK XML builder."
},
"patchedDependencies": {
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
+7 -7
View File
@@ -36,19 +36,19 @@
},
"author": "Formbricks <hola@formbricks.com>",
"dependencies": {
"@ai-sdk/amazon-bedrock": "4.0.104",
"@ai-sdk/azure": "3.0.64",
"@ai-sdk/google-vertex": "4.0.126",
"@ai-sdk/amazon-bedrock": "4.0.96",
"@ai-sdk/azure": "3.0.54",
"@ai-sdk/google-vertex": "4.0.112",
"@aws-sdk/credential-providers": "3.1017.0",
"@formbricks/logger": "workspace:*",
"@formbricks/types": "workspace:*",
"ai": "6.0.177"
"ai": "6.0.168"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@vitest/coverage-v8": "4.1.6",
"vite": "8.0.12",
"vitest": "4.1.6"
"@vitest/coverage-v8": "4.1.5",
"vite": "8.0.10",
"vitest": "4.1.5"
}
}
+3 -3
View File
@@ -44,9 +44,9 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@vitest/coverage-v8": "4.1.6",
"vite": "7.3.3",
"@vitest/coverage-v8": "4.1.5",
"vite": "7.3.2",
"vite-plugin-dts": "4.5.4",
"vitest": "4.1.6"
"vitest": "4.1.5"
}
}
+3 -3
View File
@@ -3,16 +3,16 @@
"version": "0.0.0",
"private": true,
"devDependencies": {
"@next/eslint-plugin-next": "15.5.18",
"@next/eslint-plugin-next": "15.5.15",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@vercel/style-guide": "6.0.0",
"eslint-config-next": "15.5.18",
"eslint-config-next": "15.5.15",
"eslint-config-prettier": "10.1.8",
"eslint-config-turbo": "2.8.21",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.5.2",
"@vitest/eslint-plugin": "1.6.17"
"@vitest/eslint-plugin": "1.6.16"
}
}
+1 -1
View File
@@ -66,7 +66,7 @@
"prisma": "6.19.3",
"prisma-json-types-generator": "3.6.2",
"tsx": "4.21.0",
"vite": "7.3.3",
"vite": "7.3.2",
"vite-plugin-dts": "4.5.4"
}
}
+1 -1
View File
@@ -22,7 +22,7 @@
"@formbricks/types": "workspace:*",
"autoprefixer": "10.4.27",
"clsx": "2.1.1",
"postcss": "8.5.14",
"postcss": "8.5.12",
"tailwind-merge": "3.5.0",
"tailwindcss": "3.4.19"
}
+2 -2
View File
@@ -42,7 +42,7 @@
"@formbricks/eslint-config": "workspace:*",
"@types/node": "^25.4.0",
"tsx": "^4.21.0",
"vite": "7.3.3",
"vitest": "4.1.6"
"vite": "7.3.2",
"vitest": "4.1.5"
}
}
+3 -3
View File
@@ -45,9 +45,9 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@vitest/coverage-v8": "4.1.6",
"vite": "7.3.3",
"@vitest/coverage-v8": "4.1.5",
"vite": "7.3.2",
"vite-plugin-dts": "4.5.4",
"vitest": "4.1.6"
"vitest": "4.1.5"
}
}
+2 -2
View File
@@ -43,7 +43,7 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"vite": "7.3.3",
"vitest": "4.1.6"
"vite": "7.3.2",
"vitest": "4.1.5"
}
}
+3 -3
View File
@@ -44,8 +44,8 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@vitest/coverage-v8": "4.1.6",
"vite": "7.3.3",
"vitest": "4.1.6"
"@vitest/coverage-v8": "4.1.5",
"vite": "7.3.2",
"vitest": "4.1.5"
}
}
+7 -7
View File
@@ -85,21 +85,21 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@storybook/react": "10.3.6",
"@storybook/react-vite": "10.3.6",
"@storybook/react": "10.3.5",
"@storybook/react-vite": "10.3.5",
"@tailwindcss/postcss": "4.2.4",
"@tailwindcss/vite": "4.2.4",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.4",
"@vitest/coverage-v8": "4.1.6",
"react": "19.2.6",
"react-dom": "19.2.6",
"@vitest/coverage-v8": "4.1.5",
"react": "19.2.5",
"react-dom": "19.2.5",
"rimraf": "6.1.3",
"tailwindcss": "4.2.4",
"vite": "7.3.3",
"vite": "7.3.2",
"vite-plugin-dts": "4.5.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.6"
"vitest": "4.1.5"
}
}
+2 -2
View File
@@ -65,10 +65,10 @@
"concurrently": "9.2.1",
"fake-indexeddb": "6.2.5",
"happy-dom": "20.8.9",
"postcss": "8.5.14",
"postcss": "8.5.12",
"rollup-plugin-visualizer": "7.0.1",
"tailwindcss": "4.2.4",
"vite": "7.3.3",
"vite": "7.3.2",
"vite-tsconfig-paths": "6.1.1"
}
}
+1 -1
View File
@@ -17,6 +17,6 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"vite": "7.3.3"
"vite": "7.3.2"
}
}
+1704 -1686
View File
File diff suppressed because it is too large Load Diff