Compare commits

..

16 Commits

Author SHA1 Message Date
Johannes
d0758f7526 Merge branch 'main' of https://github.com/formbricks/formbricks into feat/import-export 2026-03-30 16:23:48 +02:00
Dhruwang Jariwala
4cfb8c6d7b fix: resolve language code case mismatch in link survey rendering (#7624)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:34:20 +00:00
Anshuman Pandey
e74a51a5ff fix: sync segment state after auto-save to prevent stale reference on publish (#7619) 2026-03-30 06:51:44 +00:00
Dhruwang Jariwala
29cc6a10fe fix: prevent auto-save from overwriting survey status during publish (#7618) 2026-03-30 06:34:20 +00:00
Bhagya Amarasinghe
01f765e969 fix: migrate auth sessions to database-backed storage (#7594) 2026-03-27 07:15:06 +00:00
Anshuman Pandey
9366960f18 feat: adds support for internal webhook urls (#7577) 2026-03-27 07:04:14 +00:00
IllimarR
697dc9cc99 feat: add Estonian language support for surveys (#7574)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-27 06:12:40 +00:00
Dhruwang Jariwala
83bc272ed2 fix: prevent duplicate hobby subscriptions from race condition (#7597)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 15:50:52 +00:00
Johannes
902b8c92e2 move to blocks structure, add versioning to exports for better backwards compitability 2025-12-08 22:22:59 +01:00
Johannes
17ba0f21af Merge branch 'main' of https://github.com/formbricks/formbricks into feat/import-export 2025-12-08 14:16:10 +01:00
Johannes
a384743751 surface errors in UI 2025-11-26 16:27:19 +01:00
Johannes
dfa1c3e375 Merge branch 'main' of https://github.com/formbricks/formbricks into feat/import-export 2025-11-26 14:35:46 +01:00
Johannes
77c9302183 Code Rabbit comments 2025-11-20 23:14:46 +01:00
Johannes
88da043c00 remove plan file 2025-11-20 23:02:19 +01:00
Johannes
1cc3ceec55 clean up code 2025-11-20 23:00:07 +01:00
Johannes
50d15f6e07 draft 2025-11-20 13:50:17 +01:00
107 changed files with 2796 additions and 1204 deletions

View File

@@ -38,15 +38,6 @@ LOG_LEVEL=info
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
################
# MAIL SETUP #
################
@@ -194,6 +185,11 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf

View File

@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { GET } from "./route";
const mocks = vi.hoisted(() => {
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
const nextAuth = vi.fn(() => nextAuthHandler);
return {
nextAuth,
nextAuthHandler,
baseSignIn: vi.fn(async () => true),
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
baseEventSignIn: vi.fn(),
queueAuditEventBackground: vi.fn(),
captureException: vi.fn(),
loggerError: vi.fn(),
};
});
vi.mock("next-auth", () => ({
default: mocks.nextAuth,
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: undefined,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: mocks.captureException,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.loggerError,
})),
},
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {
callbacks: {
signIn: mocks.baseSignIn,
session: mocks.baseSession,
},
events: {
signIn: mocks.baseEventSignIn,
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
const request = new Request("http://localhost/api/auth/signin", {
headers: { "x-request-id": requestId },
});
await GET(request, {} as any);
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
return mocks.nextAuth.mock.calls[0][0];
};
describe("auth route audit logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
const authOptions = await getWrappedAuthOptions();
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
const account = { provider: "keycloak" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_1",
targetId: "user_1",
organizationId: "unknown",
status: "success",
userType: "user",
newObject: expect.objectContaining({
email: "user@example.com",
authMethod: "sso",
provider: "keycloak",
sessionStrategy: "database",
isNewUser: false,
}),
})
);
});
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
const error = new Error("Access denied");
mocks.baseSignIn.mockRejectedValueOnce(error);
const authOptions = await getWrappedAuthOptions("req-failure");
const user = { id: "user_2", email: "user2@example.com" };
const account = { provider: "credentials" };
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_2",
targetId: "user_2",
organizationId: "unknown",
status: "failure",
userType: "user",
eventId: "req-failure",
newObject: expect.objectContaining({
email: "user2@example.com",
authMethod: "password",
provider: "credentials",
errorMessage: "Access denied",
}),
})
);
});
});

View File

@@ -6,10 +6,26 @@ import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const fetchCache = "force-no-store";
const getAuthMethod = (account: Account | null) => {
if (account?.provider === "credentials") {
return "password";
}
if (account?.provider === "token") {
return "email_verification";
}
if (account?.provider) {
return "sso";
}
return "unknown";
};
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -17,44 +33,6 @@ const handler = async (req: Request, ctx: any) => {
...baseAuthOptions,
callbacks: {
...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) {
let result: any = params.session;
let error: any = undefined;
@@ -90,7 +68,7 @@ const handler = async (req: Request, ctx: any) => {
}) {
let result: boolean | string = true;
let error: any = undefined;
let authMethod = "unknown";
const authMethod = getAuthMethod(account);
try {
if (baseAuthOptions.callbacks?.signIn) {
@@ -102,15 +80,6 @@ const handler = async (req: Request, ctx: any) => {
credentials,
});
}
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) {
error = err;
result = false;
@@ -122,30 +91,60 @@ const handler = async (req: Request, ctx: any) => {
}
}
const status: TAuditStatus = result === false ? "failure" : "success";
const auditLog = {
action: "signedIn" as const,
targetType: "user" as const,
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error ? { errorMessage: error.message } : {}),
},
...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (result === false) {
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "failure",
userType: "user",
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error instanceof Error ? { errorMessage: error.message } : {}),
},
eventId,
});
}
if (error) throw error;
return result;
},
},
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "success",
userType: "user",
newObject: {
...user,
authMethod: getAuthMethod(account),
provider: account?.provider,
sessionStrategy: "database",
isNewUser: isNewUser ?? false,
},
});
},
},
};
return NextAuth(authOptions)(req, ctx);

View File

@@ -45,7 +45,6 @@ export const responseSelection = {
updatedAt: true,
name: true,
environmentId: true,
projectId: true,
},
},
},

View File

@@ -19,7 +19,6 @@ const selectActionClass = {
key: true,
noCodeConfig: true,
environmentId: true,
projectId: true,
} satisfies Prisma.ActionClassSelect;
export const getActionClasses = reactCache(async (environmentIds: string[]): Promise<TActionClass[]> => {

View File

@@ -50,7 +50,6 @@ export const responseSelection = {
updatedAt: true,
name: true,
environmentId: true,
projectId: true,
},
},
},

View File

@@ -4823,7 +4823,6 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
name: t("templates.preview_survey_name"),
type: "link" as const,
environmentId: "cltwumfcz0009echxg02fh7oa",
projectId: null,
createdBy: "cltwumfbz0000echxysz6ptvq",
status: "inProgress" as const,
welcomeCard: {

View File

@@ -0,0 +1,97 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { upsertAccount } from "./service";
const { mockUpsert } = vi.hoisted(() => ({
mockUpsert: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
upsert: mockUpsert,
},
},
}));
describe("account service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("upsertAccount keeps user ownership immutable on update", async () => {
const accountData = {
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
};
mockUpsert.mockResolvedValue({
id: "account-1",
createdAt: new Date(),
updatedAt: new Date(),
...accountData,
});
await upsertAccount(accountData);
expect(mockUpsert).toHaveBeenCalledWith({
where: {
provider_providerAccountId: {
provider: "google",
providerAccountId: "provider-1",
},
},
create: accountData,
update: {
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
},
});
});
test("upsertAccount wraps Prisma known request errors", async () => {
const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), {
message: "duplicate account",
});
mockUpsert.mockRejectedValue(prismaError);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toMatchObject({
name: "DatabaseError",
message: "duplicate account",
});
});
test("upsertAccount rethrows non-Prisma errors", async () => {
const error = new Error("unexpected failure");
mockUpsert.mockRejectedValue(error);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toThrow("unexpected failure");
});
});

View File

@@ -20,3 +20,36 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
throw error;
}
};
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
access_token: validatedAccountData.access_token,
refresh_token: validatedAccountData.refresh_token,
expires_at: validatedAccountData.expires_at,
scope: validatedAccountData.scope,
token_type: validatedAccountData.token_type,
id_token: validatedAccountData.id_token,
};
try {
const account = await prisma.account.upsert({
where: {
provider_providerAccountId: {
provider: validatedAccountData.provider,
providerAccountId: validatedAccountData.providerAccountId,
},
},
create: validatedAccountData,
update: updateAccountData,
});
return account;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -21,7 +21,6 @@ const selectActionClass = {
key: true,
noCodeConfig: true,
environmentId: true,
projectId: true,
} satisfies Prisma.ActionClassSelect;
export const getActionClasses = reactCache(

View File

@@ -26,6 +26,7 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
@@ -40,8 +41,6 @@ export const GITHUB_ID = env.GITHUB_ID;
export const GITHUB_SECRET = env.GITHUB_SECRET;
export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID;
export const GOOGLE_CLIENT_SECRET = env.GOOGLE_CLIENT_SECRET;
export const HUB_API_URL = env.HUB_API_URL;
export const HUB_API_KEY = env.HUB_API_KEY;
export const AZUREAD_CLIENT_ID = env.AZUREAD_CLIENT_ID;
export const AZUREAD_CLIENT_SECRET = env.AZUREAD_CLIENT_SECRET;

View File

@@ -15,6 +15,7 @@ export const env = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
@@ -33,8 +34,6 @@ export const env = createEnv({
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
HTTP_PROXY: z.url().optional(),
HTTPS_PROXY: z.url().optional(),
HUB_API_URL: z.url(),
HUB_API_KEY: z.string().optional(),
IMPRINT_URL: z
.url()
.optional()
@@ -143,6 +142,7 @@ export const env = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
@@ -161,8 +161,6 @@ export const env = createEnv({
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY,
HUB_API_URL: process.env.HUB_API_URL,
HUB_API_KEY: process.env.HUB_API_KEY,
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED,

View File

@@ -84,7 +84,9 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
if (!surveyLanguages?.length || !languageCode) return "default";
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
const language = surveyLanguages.find(
(surveyLanguage) => surveyLanguage.language.code.toLowerCase() === languageCode.toLowerCase()
);
return language?.default ? "default" : language?.language.code || "default";
};

View File

@@ -75,7 +75,6 @@ export const responseSelection = {
updatedAt: true,
name: true,
environmentId: true,
projectId: true,
},
},
},

View File

@@ -19,7 +19,6 @@ const selectContact = {
createdAt: true,
updatedAt: true,
environmentId: true,
projectId: true,
attributes: {
select: {
value: true,
@@ -42,7 +41,6 @@ const commonMockProperties = {
createdAt: currentDate,
updatedAt: currentDate,
environmentId: mockId,
projectId: null,
};
type SurveyMock = Prisma.SurveyGetPayload<{

View File

@@ -30,7 +30,6 @@ export const selectSurvey = {
name: true,
type: true,
environmentId: true,
projectId: true,
createdBy: true,
status: true,
welcomeCard: true,
@@ -85,7 +84,6 @@ export const selectSurvey = {
createdAt: true,
updatedAt: true,
environmentId: true,
projectId: true,
name: true,
description: true,
type: true,

View File

@@ -9,6 +9,10 @@ vi.mock("node:dns", () => ({
},
}));
vi.mock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
}));
const mockResolve = vi.mocked(dns.resolve);
const mockResolve6 = vi.mocked(dns.resolve6);
@@ -294,4 +298,78 @@ describe("validateWebhookUrl", () => {
});
});
});
describe("DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS", () => {
test("allows private IP URLs when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://127.0.0.1/")).resolves.toBeUndefined();
await expect(validateWithFlag("http://192.168.1.1/test")).resolves.toBeUndefined();
await expect(validateWithFlag("http://10.0.0.1/webhook")).resolves.toBeUndefined();
});
test("allows localhost when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost/webhook")).resolves.toBeUndefined();
await expect(validateWithFlag("http://localhost:3333/webhook")).resolves.toBeUndefined();
});
test("allows localhost.localdomain when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost.localdomain/path")).resolves.toBeUndefined();
});
test("allows hostname resolving to private IP when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(["192.168.1.1"]);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://internal.company.com/webhook")).resolves.toBeUndefined();
});
test("still rejects unresolvable hostnames when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(null, null);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://typo-gibberish.invalid/hook")).rejects.toThrow(
"Could not resolve webhook URL hostname"
);
});
test("still rejects invalid URL format when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("not-a-url")).rejects.toThrow("Invalid webhook URL format");
});
test("still rejects non-HTTP protocols when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("ftp://192.168.1.1/")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
});
});

View File

@@ -1,6 +1,7 @@
import "server-only";
import dns from "node:dns";
import { InvalidInputError } from "@formbricks/types/errors";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
@@ -139,8 +140,10 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
const hostname = parsed.hostname;
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
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
@@ -149,12 +152,17 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (isPrivateIP(ip)) {
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
}
// 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 {
@@ -168,9 +176,11 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
);
}
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
}
};

View File

@@ -1532,6 +1532,28 @@
"ignore_global_waiting_time": "Ignore Cooldown Period",
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
"image": "Image",
"import_error_invalid_json": "Invalid JSON file",
"import_error_validation": "Survey validation failed",
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
"import_survey": "Import Survey",
"import_survey_description": "Import a survey from a JSON file",
"import_survey_error": "Failed to import survey",
"import_survey_errors": "Errors",
"import_survey_file_label": "Select JSON file",
"import_survey_import": "Import Survey",
"import_survey_name_label": "Survey Name",
"import_survey_new_id": "New Survey ID",
"import_survey_success": "Survey imported successfully",
"import_survey_upload": "Upload File",
"import_survey_validate": "Validating...",
"import_survey_warnings": "Warnings",
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan and might be removed.",
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
"import_warning_multi_language": "Multi-language surveys require an enterprise plan and might be removed.",
"import_warning_recaptcha": "Spam protection requires an enterprise plan and might be disabled.",
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
"includes_all_of": "Includes all of",
"includes_one_of": "Includes one of",
"initial_value": "Initial value",
@@ -1837,6 +1859,28 @@
"complete_responses": "Complete responses",
"partial_responses": "Partial responses"
},
"import_error_invalid_json": "Invalid JSON file",
"import_error_validation": "Survey validation failed",
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
"import_survey": "Import Survey",
"import_survey_description": "Import a survey from a JSON file",
"import_survey_error": "Failed to import survey",
"import_survey_errors": "Errors",
"import_survey_file_label": "Select JSON file",
"import_survey_import": "Import Survey",
"import_survey_name_label": "Survey Name",
"import_survey_new_id": "New Survey ID",
"import_survey_success": "Survey imported successfully",
"import_survey_upload": "Upload File",
"import_survey_validate": "Validating...",
"import_survey_warnings": "Warnings",
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan. Follow-ups will be removed.",
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
"import_warning_multi_language": "Multi-language surveys require an enterprise plan. Languages will be removed.",
"import_warning_recaptcha": "Spam protection requires an enterprise plan. reCAPTCHA will be disabled.",
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
"new_survey": "New Survey",
"no_surveys_created_yet": "No surveys created yet",
"open_options": "Open options",

View File

@@ -58,7 +58,6 @@ export const getResponseForPipeline = async (
updatedAt: true,
name: true,
environmentId: true,
projectId: true,
},
},
},

View File

@@ -184,7 +184,6 @@ describe("Response Lib", () => {
updatedAt: true,
name: true,
environmentId: true,
projectId: true,
},
},
},

View File

@@ -17,7 +17,6 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
createdAt: true,
updatedAt: true,
environmentId: true,
projectId: true,
secret: true,
}).meta({
id: "webhookUpdate",

View File

@@ -10,6 +10,25 @@ import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
vi.mock("@next-auth/prisma-adapter", () => ({
PrismaAdapter: vi.fn(() => ({
createUser: vi.fn(),
getUser: vi.fn(),
getUserByEmail: vi.fn(),
getUserByAccount: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
linkAccount: vi.fn(),
unlinkAccount: vi.fn(),
createSession: vi.fn(),
getSessionAndUser: vi.fn(),
updateSession: vi.fn(),
deleteSession: vi.fn(),
createVerificationToken: vi.fn(),
useVerificationToken: vi.fn(),
})),
}));
// Mock encryption utilities
vi.mock("@/lib/encryption", () => ({
symmetricEncrypt: vi.fn((value: string) => `encrypted_${value}`),
@@ -300,51 +319,20 @@ describe("authOptions", () => {
});
describe("Callbacks", () => {
describe("jwt callback", () => {
test("should add profile information to token if user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue({
id: mockUser.id,
locale: mockUser.locale,
email: mockUser.email,
emailVerified: mockUser.emailVerified,
} as any);
const token = { email: mockUser.email };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual({
...token,
profile: { id: mockUser.id },
});
});
test("should return token unchanged if no existing user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue(null);
const token = { email: "nonexistent@example.com" };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual(token);
});
});
describe("session callback", () => {
test("should add user profile to session", async () => {
const token = {
id: "user6",
profile: { id: "user6", email: "user6@example.com" },
};
test("should add user id and isActive to session from database user", async () => {
const session = { user: { email: "user6@example.com" } };
const user = { id: "user6", isActive: false };
const session = { user: {} };
if (!authOptions.callbacks?.session) {
throw new Error("session callback is not defined");
}
const result = await authOptions.callbacks.session({ session, token } as any);
expect(result.user).toEqual(token.profile);
const result = await authOptions.callbacks.session({ session, user } as any);
expect(result.user).toEqual({
email: "user6@example.com",
id: "user6",
isActive: false,
});
});
});

View File

@@ -1,3 +1,4 @@
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
@@ -13,7 +14,7 @@ import {
} from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
logAuthEvent,
@@ -31,6 +32,7 @@ import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
id: "credentials",
@@ -310,30 +312,17 @@ export const authOptions: NextAuthOptions = {
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
session: {
strategy: "database",
maxAge: SESSION_MAX_AGE,
},
callbacks: {
async jwt({ token }) {
const existingUser = await getUserByEmail(token?.email!);
if (!existingUser) {
return token;
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
if ("isActive" in user && typeof user.isActive === "boolean") {
session.user.isActive = user.isActive;
}
}
return {
...token,
profile: { id: existingUser.id },
isActive: existingUser.isActive,
};
},
async session({ session, token }) {
// @ts-expect-error
session.user.id = token?.id;
// @ts-expect-error
session.user = token.profile;
// @ts-expect-error
session.user.isActive = token.isActive;
return session;
},
async signIn({ user, account }) {

View File

@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getProxySession, getSessionTokenFromRequest } from "./proxy-session";
const { mockFindUnique } = vi.hoisted(() => ({
mockFindUnique: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
session: {
findUnique: mockFindUnique,
},
},
}));
const createRequest = (cookies: Record<string, string> = {}) => ({
cookies: {
get: (name: string) => {
const value = cookies[name];
return value ? { value } : undefined;
},
},
});
describe("proxy-session", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("reads the secure session cookie when present", () => {
const request = createRequest({
"__Secure-next-auth.session-token": "secure-token",
});
expect(getSessionTokenFromRequest(request)).toBe("secure-token");
});
test("returns null when no session cookie is present", async () => {
const request = createRequest();
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).not.toHaveBeenCalled();
});
test("returns null when the session is expired", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() - 60_000),
user: {
isActive: true,
},
});
const request = createRequest({
"next-auth.session-token": "expired-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).toHaveBeenCalledWith({
where: {
sessionToken: "expired-token",
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
});
test("returns null when the session belongs to an inactive user", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: false,
},
});
const request = createRequest({
"next-auth.session-token": "inactive-user-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
});
test("returns the session when the cookie maps to a valid session", async () => {
const validSession = {
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: true,
},
};
mockFindUnique.mockResolvedValue(validSession);
const request = createRequest({
"next-auth.session-token": "valid-token",
});
const session = await getProxySession(request);
expect(session).toEqual(validSession);
});
});

View File

@@ -0,0 +1,54 @@
import { prisma } from "@formbricks/database";
const NEXT_AUTH_SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
] as const;
type TCookieStore = {
get: (name: string) => { value: string } | undefined;
};
type TRequestWithCookies = {
cookies: TCookieStore;
};
export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookie = request.cookies.get(cookieName);
if (cookie?.value) {
return cookie.value;
}
}
return null;
};
export const getProxySession = async (request: TRequestWithCookies) => {
const sessionToken = getSessionTokenFromRequest(request);
if (!sessionToken) {
return null;
}
const session = await prisma.session.findUnique({
where: {
sessionToken,
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
if (!session || session.expires <= new Date() || session.user.isActive === false) {
return null;
}
return session;
};

View File

@@ -106,10 +106,7 @@ describe("billing actions", () => {
});
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -128,10 +125,7 @@ describe("billing actions", () => {
} as any);
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith(
"org_1",
"start-hobby"
);
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -145,7 +139,7 @@ describe("billing actions", () => {
expect(mocks.getOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.ensureStripeCustomerForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_1");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});
@@ -165,7 +159,7 @@ describe("billing actions", () => {
expect(mocks.ensureStripeCustomerForOrganization).not.toHaveBeenCalled();
expect(mocks.createProTrialSubscription).toHaveBeenCalledWith("org_1", "cus_existing");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1", "pro-trial");
expect(mocks.reconcileCloudStripeSubscriptionsForOrganization).toHaveBeenCalledWith("org_1");
expect(mocks.syncOrganizationBillingFromStripe).toHaveBeenCalledWith("org_1");
expect(result).toEqual({ success: true });
});

View File

@@ -216,7 +216,7 @@ export const startHobbyAction = authenticatedActionClient
throw new ResourceNotFoundError("OrganizationBilling", parsedInput.organizationId);
}
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "start-hobby");
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});
@@ -248,7 +248,7 @@ export const startProTrialAction = authenticatedActionClient
}
await createProTrialSubscription(parsedInput.organizationId, customerId);
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId, "pro-trial");
await reconcileCloudStripeSubscriptionsForOrganization(parsedInput.organizationId);
await syncOrganizationBillingFromStripe(parsedInput.organizationId);
return { success: true };
});

View File

@@ -150,7 +150,7 @@ export const webhookHandler = async (requestBody: string, stripeSignature: strin
await handleSetupCheckoutCompleted(event.data.object, stripe);
}
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, event.id);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await syncOrganizationBillingFromStripe(organizationId, {
id: event.id,
created: event.created,

View File

@@ -1905,7 +1905,7 @@ describe("organization-billing", () => {
items: [{ price: "price_hobby_monthly", quantity: 1 }],
metadata: { organizationId: "org_1" },
},
{ idempotencyKey: "ensure-hobby-subscription-org_1-bootstrap" }
{ idempotencyKey: "ensure-hobby-subscription-org_1-0" }
);
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith({
where: { organizationId: "org_1" },
@@ -1974,7 +1974,7 @@ describe("organization-billing", () => {
],
});
await reconcileCloudStripeSubscriptionsForOrganization("org_1", "evt_123");
await reconcileCloudStripeSubscriptionsForOrganization("org_1");
expect(mocks.subscriptionsCancel).toHaveBeenCalledWith("sub_hobby", { prorate: false });
expect(mocks.subscriptionsCreate).not.toHaveBeenCalled();

View File

@@ -458,18 +458,21 @@ const resolvePendingChangeEffectiveAt = (
const ensureHobbySubscription = async (
organizationId: string,
customerId: string,
idempotencySuffix: string
subscriptionCount: number
): Promise<void> => {
if (!stripeClient) return;
const hobbyItems = await getCatalogItemsForPlan("hobby", "monthly");
// Include subscriptionCount so the key is stable across concurrent calls (same
// count → same key → Stripe deduplicates) but changes after a cancellation
// (count increases → new key → allows legitimate re-creation).
await stripeClient.subscriptions.create(
{
customer: customerId,
items: hobbyItems,
metadata: { organizationId },
},
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${idempotencySuffix}` }
{ idempotencyKey: `ensure-hobby-subscription-${organizationId}-${subscriptionCount}` }
);
};
@@ -1264,8 +1267,7 @@ export const findOrganizationIdByStripeCustomerId = async (customerId: string):
};
export const reconcileCloudStripeSubscriptionsForOrganization = async (
organizationId: string,
idempotencySuffix = "reconcile"
organizationId: string
): Promise<void> => {
const client = stripeClient;
if (!IS_FORMBRICKS_CLOUD || !client) return;
@@ -1342,12 +1344,14 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
// (e.g. webhook + bootstrap) both seeing 0 and creating duplicate hobbies.
const freshSubscriptions = await client.subscriptions.list({
customer: customerId,
status: "active",
limit: 1,
status: "all",
limit: 20,
});
if (freshSubscriptions.data.length === 0) {
await ensureHobbySubscription(organizationId, customerId, idempotencySuffix);
const freshActive = freshSubscriptions.data.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.has(sub.status));
if (freshActive.length === 0) {
await ensureHobbySubscription(organizationId, customerId, freshSubscriptions.data.length);
}
}
};
@@ -1355,6 +1359,6 @@ export const reconcileCloudStripeSubscriptionsForOrganization = async (
export const ensureCloudStripeSetupForOrganization = async (organizationId: string): Promise<void> => {
if (!IS_FORMBRICKS_CLOUD || !stripeClient) return;
await ensureStripeCustomerForOrganization(organizationId);
await reconcileCloudStripeSubscriptionsForOrganization(organizationId, "bootstrap");
await reconcileCloudStripeSubscriptionsForOrganization(organizationId);
await syncOrganizationBillingFromStripe(organizationId);
};

View File

@@ -98,7 +98,6 @@ const selectContact = {
createdAt: true,
updatedAt: true,
environmentId: true,
projectId: true,
attributes: {
select: {
value: true,

View File

@@ -45,7 +45,6 @@ export function CreateSegmentModal({
isPrivate: false,
filters: [],
environmentId,
projectId: null,
id: "",
surveys: [],
createdAt: new Date(),

View File

@@ -55,7 +55,6 @@ export const selectSegment = {
title: true,
description: true,
environmentId: true,
projectId: true,
filters: true,
isPrivate: true,
surveys: {

View File

@@ -3,7 +3,7 @@ import type { Account } from "next-auth";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import type { TUser, TUserNotificationSettings } from "@formbricks/types/user";
import { createAccount } from "@/lib/account/service";
import { upsertAccount } from "@/lib/account/service";
import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO } from "@/lib/constants";
import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
@@ -23,6 +23,21 @@ import {
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
const syncSsoAccount = async (userId: string, account: Account) => {
await upsertAccount({
userId,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
...(account.access_token !== undefined ? { access_token: account.access_token } : {}),
...(account.refresh_token !== undefined ? { refresh_token: account.refresh_token } : {}),
...(account.expires_at !== undefined ? { expires_at: account.expires_at } : {}),
...(account.scope !== undefined ? { scope: account.scope } : {}),
...(account.token_type !== undefined ? { token_type: account.token_type } : {}),
...(account.id_token !== undefined ? { id_token: account.id_token } : {}),
});
};
export const handleSsoCallback = async ({
user,
account,
@@ -108,6 +123,7 @@ export const handleSsoCallback = async ({
// User with this provider found
// check if email still the same
if (existingUserWithAccount.email === user.email) {
await syncSsoAccount(existingUserWithAccount.id, account);
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"SSO callback successful: existing user, email matches"
@@ -133,6 +149,7 @@ export const handleSsoCallback = async ({
);
await updateUser(existingUserWithAccount.id, { email: user.email });
await syncSsoAccount(existingUserWithAccount.id, account);
return true;
}
@@ -154,6 +171,7 @@ export const handleSsoCallback = async ({
const existingUserWithEmail = await getUserByEmail(user.email);
if (existingUserWithEmail) {
await syncSsoAccount(existingUserWithEmail.id, account);
contextLogger.debug(
{ existingUserId: existingUserWithEmail.id, action: "existing_user_login" },
"SSO callback successful: existing user found by email"
@@ -342,6 +360,7 @@ export const handleSsoCallback = async ({
// send new user to brevo
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
await syncSsoAccount(userProfile.id, account);
if (isMultiOrgEnabled) {
contextLogger.debug(
@@ -358,10 +377,6 @@ export const handleSsoCallback = async ({
"Assigning user to organization"
);
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
await createAccount({
...account,
userId: userProfile.id,
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
contextLogger.debug(

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TUser } from "@formbricks/types/user";
import { upsertAccount } from "@/lib/account/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -62,7 +63,7 @@ vi.mock("@/modules/ee/sso/lib/team", () => ({
}));
vi.mock("@/lib/account/service", () => ({
createAccount: vi.fn(),
upsertAccount: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
@@ -203,6 +204,36 @@ describe("handleSsoCallback", () => {
});
});
test("should not overwrite stored tokens when the provider omits them", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...mockUser,
email: mockUser.email,
accounts: [{ provider: mockAccount.provider }],
} as any);
const result = await handleSsoCallback({
user: mockUser,
account: {
...mockAccount,
access_token: undefined,
refresh_token: undefined,
expires_at: undefined,
scope: undefined,
token_type: undefined,
id_token: undefined,
},
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith({
userId: mockUser.id,
type: mockAccount.type,
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
});
});
test("should update user email if user with account exists but email changed", async () => {
const existingUser = {
...mockUser,

View File

@@ -11,9 +11,10 @@ import { AddWebhookModal } from "./add-webhook-modal";
interface AddWebhookButtonProps {
environment: TEnvironment;
surveys: TSurvey[];
allowInternalUrls: boolean;
}
export const AddWebhookButton = ({ environment, surveys }: AddWebhookButtonProps) => {
export const AddWebhookButton = ({ environment, surveys, allowInternalUrls }: AddWebhookButtonProps) => {
const { t } = useTranslation();
const [isAddWebhookModalOpen, setAddWebhookModalOpen] = useState(false);
return (
@@ -31,6 +32,7 @@ export const AddWebhookButton = ({ environment, surveys }: AddWebhookButtonProps
surveys={surveys}
open={isAddWebhookModalOpen}
setOpen={setAddWebhookModalOpen}
allowInternalUrls={allowInternalUrls}
/>
</>
);

View File

@@ -34,9 +34,16 @@ interface AddWebhookModalProps {
open: boolean;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
allowInternalUrls: boolean;
}
export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWebhookModalProps) => {
export const AddWebhookModal = ({
environmentId,
surveys,
open,
setOpen,
allowInternalUrls,
}: AddWebhookModalProps) => {
const router = useRouter();
const {
handleSubmit,
@@ -59,7 +66,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
sendSuccessToast: boolean
): Promise<{ success: boolean; secret?: string }> => {
try {
const { valid, error } = validWebHookURL(testEndpointInput);
const { valid, error } = validWebHookURL(testEndpointInput, allowInternalUrls);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return { success: false };

View File

@@ -23,9 +23,17 @@ interface WebhookModalProps {
webhook: Webhook;
surveys: TSurvey[];
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: WebhookModalProps) => {
export const WebhookModal = ({
open,
setOpen,
webhook,
surveys,
isReadOnly,
allowInternalUrls,
}: WebhookModalProps) => {
const { t, i18n } = useTranslation();
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
const [activeTab, setActiveTab] = useState(0);
@@ -38,7 +46,13 @@ export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: We
{
title: t("common.settings"),
children: (
<WebhookSettingsTab webhook={webhook} surveys={surveys} setOpen={setOpen} isReadOnly={isReadOnly} />
<WebhookSettingsTab
webhook={webhook}
surveys={surveys}
setOpen={setOpen}
isReadOnly={isReadOnly}
allowInternalUrls={allowInternalUrls}
/>
),
},
];

View File

@@ -26,9 +26,16 @@ interface WebhookSettingsTabProps {
surveys: TSurvey[];
setOpen: (v: boolean) => void;
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: WebhookSettingsTabProps) => {
export const WebhookSettingsTab = ({
webhook,
surveys,
setOpen,
isReadOnly,
allowInternalUrls,
}: WebhookSettingsTabProps) => {
const { t } = useTranslation();
const router = useRouter();
const { register, handleSubmit } = useForm({
@@ -60,7 +67,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
const handleTestEndpoint = async (sendSuccessToast: boolean): Promise<boolean> => {
try {
const { valid, error } = validWebHookURL(testEndpointInput);
const { valid, error } = validWebHookURL(testEndpointInput, allowInternalUrls);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return false;

View File

@@ -14,6 +14,7 @@ interface WebhookTableProps {
surveys: TSurvey[];
children: [JSX.Element, JSX.Element[]];
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookTable = ({
@@ -22,12 +23,12 @@ export const WebhookTable = ({
surveys,
children: [TableHeading, webhookRows],
isReadOnly,
allowInternalUrls,
}: WebhookTableProps) => {
const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false);
const { t } = useTranslation();
const [activeWebhook, setActiveWebhook] = useState<Webhook>({
environmentId: environment.id,
projectId: null,
id: "",
name: "",
url: "",
@@ -72,6 +73,7 @@ export const WebhookTable = ({
webhook={activeWebhook}
surveys={surveys}
isReadOnly={isReadOnly}
allowInternalUrls={allowInternalUrls}
/>
</>
);

View File

@@ -1,4 +1,4 @@
export const validWebHookURL = (urlInput: string) => {
export const validWebHookURL = (urlInput: string, allowInternalUrls = false) => {
const trimmedInput = urlInput.trim();
if (!trimmedInput) {
return { valid: false, error: "Please enter a URL" };
@@ -7,6 +7,13 @@ export const validWebHookURL = (urlInput: string) => {
try {
const url = new URL(trimmedInput);
if (allowInternalUrls) {
if (url.protocol !== "https:" && url.protocol !== "http:") {
return { valid: false, error: "URL must start with https:// or http://" };
}
return { valid: true };
}
if (url.protocol !== "https:") {
return { valid: false, error: "URL must start with https://" };
}

View File

@@ -1,3 +1,4 @@
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
import { getSurveys } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -21,13 +22,24 @@ export const WebhooksPage = async (props: { params: Promise<{ environmentId: str
getSurveys(params.environmentId, 200), // HOTFIX: not getting all surveys for now since it's maxing out the prisma accelerate limit
]);
const renderAddWebhookButton = () => <AddWebhookButton environment={environment} surveys={surveys} />;
const renderAddWebhookButton = () => (
<AddWebhookButton
environment={environment}
surveys={surveys}
allowInternalUrls={DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS}
/>
);
return (
<PageContentWrapper>
<GoBackButton />
<PageHeader pageTitle={t("common.webhooks")} cta={!isReadOnly ? renderAddWebhookButton() : <></>} />
<WebhookTable environment={environment} webhooks={webhooks} surveys={surveys} isReadOnly={isReadOnly}>
<WebhookTable
environment={environment}
webhooks={webhooks}
surveys={surveys}
isReadOnly={isReadOnly}
allowInternalUrls={DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS}>
<WebhookTableHeading />
{webhooks.map((webhook) => (
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />

View File

@@ -19,8 +19,13 @@ export const createSurvey = async (
try {
const { createdBy, ...restSurveyBody } = surveyBody;
// empty languages array
if (!restSurveyBody.languages?.length) {
const hasLanguages = Array.isArray(restSurveyBody.languages)
? restSurveyBody.languages.length > 0
: restSurveyBody.languages &&
typeof restSurveyBody.languages === "object" &&
"create" in restSurveyBody.languages;
if (!hasLanguages) {
delete restSurveyBody.languages;
}

View File

@@ -49,7 +49,6 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
isPrivate: true,
title: localSurvey.id,
environmentId: environment.id,
projectId: null,
surveys: [localSurvey.id],
filters: [],
createdAt: new Date(),

View File

@@ -72,6 +72,7 @@ export const SurveyMenuBar = ({
const [lastAutoSaved, setLastAutoSaved] = useState<Date | null>(null);
const isSuccessfullySavedRef = useRef(false);
const isAutoSavingRef = useRef(false);
const isSurveyPublishingRef = useRef(false);
// Refs for interval-based auto-save (to access current values without re-creating interval)
const localSurveyRef = useRef(localSurvey);
@@ -269,8 +270,8 @@ export const SurveyMenuBar = ({
// Skip if tab is not visible (no computation, no API calls for background tabs)
if (document.hidden) return;
// Skip if already saving (manual or auto)
if (isAutoSavingRef.current || isSurveySavingRef.current) return;
// Skip if already saving, publishing, or auto-saving
if (isAutoSavingRef.current || isSurveySavingRef.current || isSurveyPublishingRef.current) return;
// Check for changes using refs (avoids re-creating interval on every change)
const { updatedAt: localUpdatedAt, ...localSurveyRest } = localSurveyRef.current;
@@ -289,10 +290,19 @@ export const SurveyMenuBar = ({
} as unknown as TSurveyDraft);
if (updatedSurveyResponse?.data) {
const savedData = updatedSurveyResponse.data;
// If the segment changed on the server (e.g., private segment was deleted when
// switching from app to link type), update localSurvey to prevent stale segment
// references when publishing
if (!isEqual(localSurveyRef.current.segment, savedData.segment)) {
setLocalSurvey({ ...localSurveyRef.current, segment: savedData.segment });
}
// Update surveyRef (not localSurvey state) to prevent re-renders during auto-save.
// This keeps the UI stable while still tracking that changes have been saved.
// The comparison uses refs, so this prevents unnecessary re-saves.
surveyRef.current = { ...updatedSurveyResponse.data };
surveyRef.current = { ...savedData };
isSuccessfullySavedRef.current = true;
setLastAutoSaved(new Date());
}
@@ -417,11 +427,13 @@ export const SurveyMenuBar = ({
};
const handleSurveyPublish = async () => {
isSurveyPublishingRef.current = true;
setIsSurveyPublishing(true);
const isSurveyValidatedWithZod = validateSurveyWithZod();
if (!isSurveyValidatedWithZod) {
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
return;
}
@@ -429,6 +441,7 @@ export const SurveyMenuBar = ({
try {
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode, t, responseCount);
if (!isSurveyValidResult) {
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
return;
}
@@ -445,10 +458,12 @@ export const SurveyMenuBar = ({
if (!publishResult?.data) {
const errorMessage = getFormattedErrorMessage(publishResult);
toast.error(errorMessage);
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
return;
}
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
// Set flag to prevent beforeunload warning during navigation
isSuccessfullySavedRef.current = true;
@@ -456,6 +471,7 @@ export const SurveyMenuBar = ({
} catch (error) {
console.error(error);
toast.error(t("environments.surveys.edit.error_publishing_survey"));
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
}
};

View File

@@ -14,7 +14,6 @@ export const selectSurvey = {
name: true,
type: true,
environmentId: true,
projectId: true,
createdBy: true,
status: true,
welcomeCard: true,
@@ -70,7 +69,6 @@ export const selectSurvey = {
createdAt: true,
updatedAt: true,
environmentId: true,
projectId: true,
name: true,
description: true,
type: true,
@@ -86,7 +84,6 @@ export const selectSurvey = {
createdAt: true,
updatedAt: true,
environmentId: true,
projectId: true,
title: true,
description: true,
isPrivate: true,

View File

@@ -202,7 +202,7 @@ function getLanguageCode(langParam: string | undefined, survey: TSurvey): string
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.code.toLowerCase() === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});

View File

@@ -3,6 +3,7 @@
import { z } from "zod";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
import { getProject } from "@/lib/project/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
@@ -14,12 +15,31 @@ import {
} from "@/lib/utils/helper";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getSurvey as getSurveyFull } from "@/modules/survey/lib/survey";
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
import { ZSurveyExportPayload, transformSurveyForExport } from "@/modules/survey/list/lib/export-survey";
import {
type TSurveyLanguageConnection,
addLanguageLabels,
mapLanguages,
mapTriggers,
normalizeLanguagesForCreation,
parseSurveyPayload,
persistSurvey,
resolveImportCapabilities,
} from "@/modules/survey/list/lib/import";
import {
buildImportWarnings,
detectImagesInSurvey,
getLanguageNames,
stripUnavailableFeatures,
} from "@/modules/survey/list/lib/import-helpers";
import { getUserProjects } from "@/modules/survey/list/lib/project";
import {
copySurveyToOtherEnvironment,
deleteSurvey,
getSurvey,
getSurvey as getSurveyMinimal,
getSurveys,
} from "@/modules/survey/list/lib/survey";
@@ -46,7 +66,35 @@ export const getSurveyAction = authenticatedActionClient
],
});
return await getSurvey(parsedInput.surveyId);
return await getSurveyMinimal(parsedInput.surveyId);
});
const ZExportSurveyAction = z.object({
surveyId: z.string().cuid2(),
});
export const exportSurveyAction = authenticatedActionClient
.schema(ZExportSurveyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurveyFull(parsedInput.surveyId);
return transformSurveyForExport(survey);
});
const ZCopySurveyToOtherEnvironmentAction = z.object({
@@ -246,3 +294,168 @@ export const getSurveysAction = authenticatedActionClient
parsedInput.filterCriteria
);
});
const ZValidateSurveyImportAction = z.object({
surveyData: ZSurveyExportPayload,
environmentId: z.string().cuid2(),
});
export const validateSurveyImportAction = authenticatedActionClient
.schema(ZValidateSurveyImportAction)
.action(async ({ ctx, parsedInput }) => {
// Step 1: Parse and validate payload structure
const parseResult = parseSurveyPayload(parsedInput.surveyData);
if ("error" in parseResult) {
return {
valid: false,
errors:
parseResult.details && parseResult.details.length > 0
? [parseResult.error, ...parseResult.details]
: [parseResult.error],
warnings: [],
infos: [],
surveyName: parsedInput.surveyData.data.name || "",
};
}
const { surveyInput, exportedLanguages, triggers } = parseResult;
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
// Trigger validation is now handled by Zod schema validation
const languageCodes = exportedLanguages.map((l) => l.code).filter(Boolean);
if (languageCodes.length > 0) {
const project = await getProject(projectId);
const existingLanguageCodes = project?.languages.map((l) => l.code) || [];
const missingLanguages = languageCodes.filter((code: string) => !existingLanguageCodes.includes(code));
if (missingLanguages.length > 0) {
const languageNames = getLanguageNames(missingLanguages);
return {
valid: false,
errors: [
`Before you can continue, please setup the following languages in your Project Configuration: ${languageNames.join(", ")}`,
],
warnings: [],
infos: [],
surveyName: surveyInput.name || "",
};
}
}
const warnings = await buildImportWarnings(surveyInput, organizationId);
const infos: string[] = [];
const hasImages = detectImagesInSurvey(surveyInput);
if (hasImages) {
warnings.push("import_warning_images");
}
if (triggers && triggers.length > 0) {
infos.push("import_info_triggers");
}
infos.push("import_info_quotas");
return {
valid: true,
errors: [],
warnings,
infos,
surveyName: surveyInput.name || "Imported Survey",
};
});
const ZImportSurveyAction = z.object({
surveyData: ZSurveyExportPayload,
environmentId: z.string().cuid2(),
newName: z.string(),
});
export const importSurveyAction = authenticatedActionClient
.schema(ZImportSurveyAction)
.action(async ({ ctx, parsedInput }) => {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
// Step 1: Parse and validate survey payload
const parseResult = parseSurveyPayload(parsedInput.surveyData);
if ("error" in parseResult) {
const errorMessage =
parseResult.details && parseResult.details.length > 0
? `${parseResult.error}:\n${parseResult.details.join("\n")}`
: parseResult.error;
throw new Error(`Validation failed: ${errorMessage}`);
}
const { surveyInput, exportedLanguages, triggers } = parseResult;
const capabilities = await resolveImportCapabilities(organizationId);
const triggerResult = await mapTriggers(triggers, parsedInput.environmentId);
const cleanedSurvey = await stripUnavailableFeatures(surveyInput, parsedInput.environmentId);
let mappedLanguages: TSurveyLanguageConnection | undefined = undefined;
let languageCodes: string[] = [];
if (exportedLanguages.length > 0 && capabilities.hasMultiLanguage) {
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
const langResult = await mapLanguages(exportedLanguages, projectId);
if (langResult.mapped.length > 0) {
mappedLanguages = normalizeLanguagesForCreation(langResult.mapped);
languageCodes = exportedLanguages.filter((l) => !l.default).map((l) => l.code);
}
}
const surveyWithTranslations = addLanguageLabels(cleanedSurvey, languageCodes);
const result = await persistSurvey(
parsedInput.environmentId,
surveyWithTranslations,
parsedInput.newName,
ctx.user.id,
triggerResult.mapped,
mappedLanguages
);
return result;
} catch (error) {
throw error;
}
});

View File

@@ -0,0 +1,26 @@
"use client";
import { UploadIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { ImportSurveyModal } from "./import-survey-modal";
interface ImportSurveyButtonProps {
environmentId: string;
}
export const ImportSurveyButton = ({ environmentId }: ImportSurveyButtonProps) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<>
<Button size="sm" variant="secondary" onClick={() => setOpen(true)}>
<UploadIcon className="mr-2 h-4 w-4" />
{t("environments.surveys.import_survey")}
</Button>
<ImportSurveyModal environmentId={environmentId} open={open} setOpen={setOpen} />
</>
);
};

View File

@@ -0,0 +1,320 @@
"use client";
import { ArrowUpFromLineIcon, CheckIcon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { importSurveyAction, validateSurveyImportAction } from "@/modules/survey/list/actions";
import { type TSurveyExportPayload } from "@/modules/survey/list/lib/export-survey";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface ImportSurveyModalProps {
environmentId: string;
open: boolean;
setOpen: (open: boolean) => void;
}
export const ImportSurveyModal = ({ environmentId, open, setOpen }: ImportSurveyModalProps) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [fileName, setFileName] = useState<string>("");
const [surveyData, setSurveyData] = useState<TSurveyExportPayload | null>(null);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
const [validationInfos, setValidationInfos] = useState<string[]>([]);
const [newName, setNewName] = useState("");
const [isValid, setIsValid] = useState(false);
const resetState = () => {
setFileName("");
setSurveyData(null);
setValidationErrors([]);
setValidationWarnings([]);
setValidationInfos([]);
setNewName("");
setIsLoading(false);
setIsValid(false);
};
const onOpenChange = (open: boolean) => {
if (!open) {
resetState();
}
setOpen(open);
};
const processJSONFile = async (file: File) => {
if (!file) return;
if (file.type !== "application/json" && !file.name.endsWith(".json")) {
toast.error(t("environments.surveys.import_error_invalid_json"));
setValidationErrors([t("environments.surveys.import_error_invalid_json")]);
setFileName("");
setIsValid(false);
return;
}
setFileName(file.name);
setIsLoading(true);
const reader = new FileReader();
reader.onload = async (event) => {
try {
const json = JSON.parse(event.target?.result as string);
setSurveyData(json);
const result = await validateSurveyImportAction({
surveyData: json,
environmentId,
});
if (result?.data) {
setValidationErrors(result.data.errors || []);
setValidationWarnings(result.data.warnings || []);
setValidationInfos(result.data.infos || []);
setIsValid(result.data.valid);
if (result.data.valid) {
setNewName(result.data.surveyName + " (imported)");
}
} else if (result?.serverError) {
setValidationErrors([result.serverError]);
setValidationWarnings([]);
setValidationInfos([]);
setIsValid(false);
}
} catch (error) {
toast.error(t("environments.surveys.import_error_invalid_json"));
setValidationErrors([t("environments.surveys.import_error_invalid_json")]);
setValidationWarnings([]);
setValidationInfos([]);
setIsValid(false);
} finally {
setIsLoading(false);
}
};
reader.readAsText(file);
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
processJSONFile(file);
}
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files[0];
if (file) {
processJSONFile(file);
}
};
const handleImport = async () => {
if (!surveyData) {
toast.error(t("environments.surveys.import_survey_error"));
return;
}
setIsLoading(true);
try {
const result = await importSurveyAction({
surveyData,
environmentId,
newName,
});
if (result?.data) {
toast.success(t("environments.surveys.import_survey_success"));
onOpenChange(false);
window.location.href = `/environments/${environmentId}/surveys/${result.data.surveyId}/edit`;
} else if (result?.serverError) {
console.error("[Import Survey] Server error:", result.serverError);
toast.error(result.serverError);
} else {
console.error("[Import Survey] Unknown error - no data or serverError returned");
toast.error(t("environments.surveys.import_survey_error"));
}
} catch (error) {
console.error("[Import Survey] Exception caught:", error);
const errorMessage =
error instanceof Error ? error.message : t("environments.surveys.import_survey_error");
toast.error(errorMessage);
} finally {
setIsLoading(false);
}
};
const renderUploadSection = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
</div>
);
}
if (!fileName) {
return (
<label
htmlFor="import-file"
className={cn(
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg hover:bg-slate-100"
)}
onDragOver={handleDragOver}
onDrop={handleDrop}>
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className="mt-2 text-center text-sm text-slate-500">
<span className="font-semibold">{t("common.upload_input_description")}</span>
</p>
<p className="text-xs text-slate-400">.json files only</p>
<Input
id="import-file"
type="file"
accept=".json"
className="hidden"
onChange={handleFileChange}
/>
</div>
</label>
);
}
return (
<div className="flex flex-col items-center gap-4 py-4">
<div className="flex items-center gap-2">
<CheckIcon className="h-5 w-5 text-green-600" />
<span className="text-sm font-medium text-slate-700">{fileName}</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => {
resetState();
document.getElementById("import-file-retry")?.click();
}}>
{t("environments.contacts.upload_contacts_modal_pick_different_file")}
</Button>
<Input
id="import-file-retry"
type="file"
accept=".json"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{t("environments.surveys.import_survey")}</DialogTitle>
<DialogDescription>{t("environments.surveys.import_survey_description")}</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="flex flex-col gap-4">
<div className="rounded-md border-2 border-dashed border-slate-300 bg-slate-50 p-4">
{renderUploadSection()}
</div>
{validationErrors.length > 0 && (
<Alert variant="error">
<AlertTitle>{t("environments.surveys.import_survey_errors")}</AlertTitle>
<AlertDescription className="max-h-60 overflow-y-auto">
<ul className="space-y-2 text-sm">
{validationErrors.map((error, i) => {
// Check if the error contains a field path (format: 'Field "path":')
const fieldMatch = error.match(/^Field "([^"]+)": (.+)$/);
if (fieldMatch) {
return (
<li key={i} className="flex flex-col gap-1">
<code className="rounded bg-red-50 px-1.5 py-0.5 font-mono text-xs text-red-800">
{fieldMatch[1]}
</code>
<span className="text-slate-700">{fieldMatch[2]}</span>
</li>
);
}
return (
<li key={i} className="text-slate-700">
{error}
</li>
);
})}
</ul>
</AlertDescription>
</Alert>
)}
{validationWarnings.length > 0 && (
<Alert variant="warning">
<AlertTitle>{t("environments.surveys.import_survey_warnings")}</AlertTitle>
<AlertDescription className="max-h-60 overflow-y-auto">
<ul className="list-disc pl-4 text-sm">
{validationWarnings.map((warningKey, i) => (
<li key={i}>{t(`environments.surveys.${warningKey}`)}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{validationInfos.length > 0 && (
<Alert variant="info">
<AlertDescription className="max-h-60 overflow-y-auto">
<ul className="list-disc pl-4 text-sm">
{validationInfos.map((infoKey, i) => (
<li key={i}>{t(`environments.surveys.${infoKey}`)}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{isValid && fileName && (
<div className="space-y-2">
<Label htmlFor="survey-name">{t("environments.surveys.import_survey_name_label")}</Label>
<Input id="survey-name" value={newName} onChange={(e) => setNewName(e.target.value)} />
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
{t("common.cancel")}
</Button>
<Button
onClick={handleImport}
loading={isLoading}
disabled={!isValid || !fileName || validationErrors.length > 0 || isLoading}>
{t("environments.surveys.import_survey_import")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -3,6 +3,7 @@
import {
ArrowUpFromLineIcon,
CopyIcon,
DownloadIcon,
EyeIcon,
LinkIcon,
MoreVertical,
@@ -22,8 +23,10 @@ import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import {
copySurveyToOtherEnvironmentAction,
deleteSurveyAction,
exportSurveyAction,
getSurveyAction,
} from "@/modules/survey/list/actions";
import { downloadSurveyJson } from "@/modules/survey/list/lib/download-survey";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
@@ -55,7 +58,7 @@ export const SurveyDropDownMenu = ({
onSurveysCopied,
}: SurveyDropDownMenuProps) => {
const { t } = useTranslation();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
@@ -77,6 +80,7 @@ export const SurveyDropDownMenu = ({
deleteSurvey(surveyId);
toast.success(t("environments.surveys.survey_deleted_successfully"));
} catch (error) {
logger.error(error);
toast.error(t("environments.surveys.error_deleting_survey"));
} finally {
setLoading(false);
@@ -87,7 +91,6 @@ export const SurveyDropDownMenu = ({
try {
e.preventDefault();
setIsDropDownOpen(false);
// For single-use surveys, this button is disabled, so we just copy the base link
const copiedLink = copySurveyLink(surveyLink);
navigator.clipboard.writeText(copiedLink);
toast.success(t("common.copied_to_clipboard"));
@@ -118,6 +121,7 @@ export const SurveyDropDownMenu = ({
toast.error(errorMessage);
}
} catch (error) {
logger.error(error);
toast.error(t("environments.surveys.survey_duplication_error"));
}
setLoading(false);
@@ -129,6 +133,32 @@ export const SurveyDropDownMenu = ({
setIsCautionDialogOpen(true);
};
const handleExportSurvey = async () => {
const exportPromise = exportSurveyAction({ surveyId: survey.id }).then((result) => {
if (result?.data) {
downloadSurveyJson(survey.name, JSON.stringify(result.data, null, 2));
return result.data;
} else if (result?.serverError) {
throw new Error(result.serverError);
}
throw new Error(t("environments.surveys.export_survey_error"));
});
toast.promise(exportPromise, {
loading: t("environments.surveys.export_survey_loading"),
success: t("environments.surveys.export_survey_success"),
error: (err) => err.message || t("environments.surveys.export_survey_error"),
});
try {
await exportPromise;
} catch (error) {
logger.error(error);
} finally {
setIsDropDownOpen(false);
}
};
return (
<div
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
@@ -189,6 +219,21 @@ export const SurveyDropDownMenu = ({
</button>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<button
type="button"
className="flex w-full items-center"
disabled={loading}
onClick={(e) => {
e.preventDefault();
handleExportSurvey();
}}>
<DownloadIcon className="mr-2 h-4 w-4" />
{t("environments.surveys.export_survey")}
</button>
</DropdownMenuItem>
{survey.type === "link" && survey.status !== "draft" && (
<>
<DropdownMenuItem>
@@ -233,7 +278,7 @@ export const SurveyDropDownMenu = ({
onClick={(e) => {
e.preventDefault();
setIsDropDownOpen(false);
setDeleteDialogOpen(true);
setIsDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
{t("common.delete")}
@@ -248,7 +293,7 @@ export const SurveyDropDownMenu = ({
<DeleteDialog
deleteWhat={t("common.survey")}
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={() => handleDeleteSurvey(survey.id)}
text={t("environments.surveys.delete_survey_and_responses_warning")}
isDeleting={loading}

View File

@@ -0,0 +1,10 @@
export const downloadSurveyJson = (surveyName: string, jsonContent: string) => {
const blob = new Blob([jsonContent], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const timestamp = new Date().toISOString().split("T")[0];
link.href = url;
link.download = `${surveyName}-export-${timestamp}.json`;
link.click();
URL.revokeObjectURL(url);
};

View File

@@ -0,0 +1,145 @@
import { z } from "zod";
import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { ZActionClassNoCodeConfig, ZActionClassType } from "@formbricks/types/action-classes";
import { type TSurvey } from "@formbricks/types/surveys/types";
// Schema for exported action class (subset of full action class)
export const ZExportedActionClass = z.object({
name: z.string(),
description: z.string().nullable(),
type: ZActionClassType,
key: z.string().nullable(),
noCodeConfig: ZActionClassNoCodeConfig.nullable(),
});
export type TExportedActionClass = z.infer<typeof ZExportedActionClass>;
// Schema for exported trigger
export const ZExportedTrigger = z.object({
actionClass: ZExportedActionClass,
});
export type TExportedTrigger = z.infer<typeof ZExportedTrigger>;
// Schema for exported language
export const ZExportedLanguage = z.object({
code: z.string(),
enabled: z.boolean(),
default: z.boolean(),
});
export type TExportedLanguage = z.infer<typeof ZExportedLanguage>;
// Current export format version
export const SURVEY_EXPORT_VERSION = "1.0.0";
// Survey data schema - the actual survey content (nested under "data" in export)
export const ZSurveyExportData = z.object({
// Use the input shape from ZSurveyCreateInput and override what we need
name: z.string(),
type: z.string().optional(),
status: z.string().optional(),
displayOption: z.string().optional(),
environmentId: z.string().optional(),
createdBy: z.string().optional(),
autoClose: z.number().nullable().optional(),
recontactDays: z.number().nullable().optional(),
displayLimit: z.number().nullable().optional(),
delay: z.number().optional(),
displayPercentage: z.number().nullable().optional(),
autoComplete: z.number().nullable().optional(),
isVerifyEmailEnabled: z.boolean().optional(),
isSingleResponsePerEmailEnabled: z.boolean().optional(),
isBackButtonHidden: z.boolean().optional(),
pin: z.string().nullable().optional(),
welcomeCard: z.any().optional(),
blocks: z.array(z.any()),
endings: z.array(z.any()).optional(),
hiddenFields: z.any().optional(),
variables: z.array(z.any()).optional(),
surveyClosedMessage: z.any().optional(),
styling: z.any().optional(),
showLanguageSwitch: z.boolean().nullable().optional(),
recaptcha: z.any().optional(),
metadata: z.any().optional(),
triggers: z.array(ZExportedTrigger).default([]),
languages: z.array(ZExportedLanguage).default([]),
followUps: z.array(ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true })).default([]),
});
export type TSurveyExportData = z.infer<typeof ZSurveyExportData>;
// Full export payload with version and metadata wrapper
export const ZSurveyExportPayload = z.object({
version: z.string(),
exportDate: z.string().datetime(),
data: ZSurveyExportData,
});
export type TSurveyExportPayload = z.infer<typeof ZSurveyExportPayload>;
export const transformSurveyForExport = (survey: TSurvey): TSurveyExportPayload => {
const surveyData: TSurveyExportData = {
name: survey.name,
type: survey.type,
status: survey.status,
displayOption: survey.displayOption,
autoClose: survey.autoClose,
recontactDays: survey.recontactDays,
displayLimit: survey.displayLimit,
delay: survey.delay,
displayPercentage: survey.displayPercentage,
autoComplete: survey.autoComplete,
isVerifyEmailEnabled: survey.isVerifyEmailEnabled,
isSingleResponsePerEmailEnabled: survey.isSingleResponsePerEmailEnabled,
isBackButtonHidden: survey.isBackButtonHidden,
pin: survey.pin,
welcomeCard: survey.welcomeCard,
blocks: survey.blocks,
endings: survey.endings,
hiddenFields: survey.hiddenFields,
variables: survey.variables,
surveyClosedMessage: survey.surveyClosedMessage,
styling: survey.styling,
showLanguageSwitch: survey.showLanguageSwitch,
recaptcha: survey.recaptcha,
metadata: survey.metadata,
triggers:
survey.triggers?.map(
(t): TExportedTrigger => ({
actionClass: {
name: t.actionClass.name,
description: t.actionClass.description,
type: t.actionClass.type,
key: t.actionClass.key,
noCodeConfig: t.actionClass.noCodeConfig,
},
})
) ?? [],
languages:
survey.languages?.map(
(l): TExportedLanguage => ({
enabled: l.enabled,
default: l.default,
code: l.language.code,
})
) ?? [],
followUps:
survey.followUps?.map((f) => ({
id: f.id,
surveyId: f.surveyId,
name: f.name,
trigger: f.trigger,
action: f.action,
})) ?? [],
};
return {
version: SURVEY_EXPORT_VERSION,
exportDate: new Date().toISOString(),
data: surveyData,
};
};

View File

@@ -0,0 +1,119 @@
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { iso639Languages } from "@/lib/i18n/utils";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { type TExportedLanguage, type TExportedTrigger } from "./export-survey";
import {
type TMappedTrigger,
type TSurveyLanguageConnection,
mapLanguages,
mapTriggers,
normalizeLanguagesForCreation,
resolveImportCapabilities,
stripUnavailableFeatures as stripFeatures,
} from "./import";
export const getLanguageNames = (languageCodes: string[]): string[] => {
return languageCodes.map((code) => {
const language = iso639Languages.find((lang) => lang.alpha2 === code);
return language ? language.label["en-US"] : code;
});
};
export const mapExportedLanguagesToPrismaCreate = async (
exportedLanguages: TExportedLanguage[],
projectId: string
): Promise<TSurveyLanguageConnection | undefined> => {
const result = await mapLanguages(exportedLanguages, projectId);
return normalizeLanguagesForCreation(result.mapped);
};
export const mapOrCreateActionClasses = async (
importedTriggers: TExportedTrigger[],
environmentId: string
): Promise<TMappedTrigger[]> => {
const result = await mapTriggers(importedTriggers, environmentId);
return result.mapped;
};
export const stripUnavailableFeatures = async (
survey: TSurveyCreateInput,
environmentId: string
): Promise<TSurveyCreateInput> => {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const capabilities = await resolveImportCapabilities(organizationId);
return stripFeatures(survey, capabilities);
};
export const buildImportWarnings = async (
survey: TSurveyCreateInput,
organizationId: string
): Promise<string[]> => {
const warnings: string[] = [];
if (survey.languages?.length) {
try {
await checkMultiLanguagePermission(organizationId);
} catch (e) {
warnings.push("import_warning_multi_language");
}
}
if (survey.followUps?.length) {
let hasFollowUps = false;
try {
const organizationBilling = await getOrganizationBilling(organizationId);
if (organizationBilling) {
hasFollowUps = await getSurveyFollowUpsPermission(organizationBilling.plan);
}
} catch (e) {}
if (!hasFollowUps) {
warnings.push("import_warning_follow_ups");
}
}
if (survey.recaptcha?.enabled) {
try {
await checkSpamProtectionPermission(organizationId);
} catch (e) {
warnings.push("import_warning_recaptcha");
}
}
if (survey.segment) {
warnings.push("import_warning_segments");
}
if (survey.triggers?.length) {
warnings.push("import_warning_action_classes");
}
return warnings;
};
export const detectImagesInSurvey = (survey: TSurveyCreateInput): boolean => {
if (survey.welcomeCard?.fileUrl || survey.welcomeCard?.videoUrl) return true;
// Check blocks for images
if (survey.blocks) {
for (const block of survey.blocks) {
for (const element of block.elements) {
if (element.imageUrl || element.videoUrl) return true;
if (element.type === "pictureSelection" && element.choices?.some((c) => c.imageUrl)) {
return true;
}
}
}
}
if (survey.endings && survey.endings.length > 0) {
for (const e of survey.endings) {
if (e.type === "endScreen" && (e.imageUrl || e.videoUrl)) return true;
}
}
return false;
};

View File

@@ -0,0 +1,11 @@
export { mapLanguages, type TMappedLanguage } from "./map-languages";
export { mapTriggers, type TMappedTrigger } from "./map-triggers";
export {
addLanguageLabels,
normalizeLanguagesForCreation,
stripUnavailableFeatures,
type TSurveyLanguageConnection,
} from "./normalize-survey";
export { parseSurveyPayload, type TParsedPayload } from "./parse-payload";
export { resolveImportCapabilities, type TImportCapabilities } from "./permissions";
export { persistSurvey } from "./persist-survey";

View File

@@ -0,0 +1,41 @@
import { getProject } from "@/lib/project/service";
import { type TExportedLanguage } from "../export-survey";
export interface TMappedLanguage {
languageId: string;
enabled: boolean;
default: boolean;
}
export const mapLanguages = async (
exportedLanguages: TExportedLanguage[],
projectId: string
): Promise<{ mapped: TMappedLanguage[]; skipped: string[] }> => {
if (!exportedLanguages || exportedLanguages.length === 0) {
return { mapped: [], skipped: [] };
}
const project = await getProject(projectId);
if (!project) {
return { mapped: [], skipped: ["Project not found"] };
}
const mappedLanguages: TMappedLanguage[] = [];
const skipped: string[] = [];
for (const exportedLang of exportedLanguages) {
const projectLanguage = project.languages.find((l) => l.code === exportedLang.code);
if (!projectLanguage) {
skipped.push(`Language ${exportedLang.code} not found in project`);
continue;
}
mappedLanguages.push({
languageId: projectLanguage.id,
enabled: exportedLang.enabled,
default: exportedLang.default,
});
}
return { mapped: mappedLanguages, skipped };
};

View File

@@ -0,0 +1,55 @@
import { TActionClassInput } from "@formbricks/types/action-classes";
import { createActionClass } from "@/modules/survey/editor/lib/action-class";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { type TExportedTrigger } from "../export-survey";
export interface TMappedTrigger {
actionClass: { id: string };
}
export const mapTriggers = async (
importedTriggers: TExportedTrigger[],
environmentId: string
): Promise<{ mapped: TMappedTrigger[]; skipped: string[] }> => {
if (!importedTriggers || importedTriggers.length === 0) {
return { mapped: [], skipped: [] };
}
const existingActionClasses = await getActionClasses(environmentId);
const mappedTriggers: TMappedTrigger[] = [];
const skipped: string[] = [];
for (const trigger of importedTriggers) {
const ac = trigger.actionClass;
let existing = existingActionClasses.find((e) => e.key === ac.key && e.type === ac.type);
if (!existing) {
try {
const actionClassInput: TActionClassInput = {
environmentId,
name: `${ac.name} (imported)`,
description: ac.description ?? null,
type: ac.type,
key: ac.key,
noCodeConfig: ac.noCodeConfig,
};
existing = await createActionClass(environmentId, actionClassInput);
} catch (error) {
existing = await getActionClasses(environmentId).then((classes) =>
classes.find((e) => e.key === ac.key && e.type === ac.type)
);
}
}
if (existing) {
mappedTriggers.push({
actionClass: { id: existing.id },
});
} else {
skipped.push(`Could not find or create action class: ${ac.name} (${ac.key ?? "no key"})`);
}
}
return { mapped: mappedTriggers, skipped };
};

View File

@@ -0,0 +1,60 @@
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { addMultiLanguageLabels } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { type TMappedLanguage } from "./map-languages";
import { type TImportCapabilities } from "./permissions";
export const stripUnavailableFeatures = (
survey: TSurveyCreateInput,
capabilities: TImportCapabilities
): TSurveyCreateInput => {
const cloned = structuredClone(survey);
if (!capabilities.hasMultiLanguage) {
cloned.languages = [];
}
if (!capabilities.hasFollowUps) {
cloned.followUps = [];
}
if (!capabilities.hasRecaptcha) {
cloned.recaptcha = null;
}
delete cloned.segment;
return cloned;
};
export interface TSurveyLanguageConnection {
create: { languageId: string; enabled: boolean; default: boolean }[];
}
export const normalizeLanguagesForCreation = (
languages: TMappedLanguage[]
): TSurveyLanguageConnection | undefined => {
if (!languages || languages.length === 0) {
return undefined;
}
return {
create: languages.map((lang) => ({
languageId: lang.languageId,
enabled: lang.enabled,
default: lang.default,
})),
};
};
export const addLanguageLabels = (
survey: TSurveyCreateInput,
languageCodes: string[]
): TSurveyCreateInput => {
if (!languageCodes || languageCodes.length === 0) {
return survey;
}
const cloned = structuredClone(survey);
return addMultiLanguageLabels(cloned, languageCodes);
};

View File

@@ -0,0 +1,98 @@
import { z } from "zod";
import { TSurveyCreateInput, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import {
SURVEY_EXPORT_VERSION,
type TExportedLanguage,
type TExportedTrigger,
ZExportedLanguage,
ZExportedTrigger,
ZSurveyExportPayload,
} from "../export-survey";
export interface TParsedPayload {
surveyInput: TSurveyCreateInput;
exportedLanguages: TExportedLanguage[];
triggers: TExportedTrigger[];
}
export interface TParseError {
error: string;
details?: string[];
}
export const parseSurveyPayload = (surveyData: unknown): TParsedPayload | TParseError => {
if (typeof surveyData !== "object" || surveyData === null) {
return { error: "Invalid survey data: expected an object" };
}
let actualSurveyData: Record<string, unknown>;
// Check if this is the new versioned format (with version, exportDate, and data wrapper)
const versionedFormatCheck = ZSurveyExportPayload.safeParse(surveyData);
if (versionedFormatCheck.success) {
// New format: extract the data from the wrapper
const { version, data } = versionedFormatCheck.data;
// Validate version (for future compatibility)
if (version !== SURVEY_EXPORT_VERSION) {
console.warn(
`Import: Survey export version ${version} differs from current version ${SURVEY_EXPORT_VERSION}`
);
}
actualSurveyData = data as Record<string, unknown>;
} else {
// Legacy format or pre-versioning format: use data as-is
actualSurveyData = surveyData as Record<string, unknown>;
}
const surveyDataCopy = { ...actualSurveyData } as Record<string, unknown>;
// Validate and extract languages
const languagesResult = z.array(ZExportedLanguage).safeParse(surveyDataCopy.languages ?? []);
if (!languagesResult.success) {
return {
error: "Invalid languages format",
details: languagesResult.error.errors.map((e) => {
const path = e.path.length > 0 ? `languages.${e.path.join(".")}` : "languages";
return `Field "${path}": ${e.message}`;
}),
};
}
const exportedLanguages = languagesResult.data;
// Validate and extract triggers
const triggersResult = z.array(ZExportedTrigger).safeParse(surveyDataCopy.triggers ?? []);
if (!triggersResult.success) {
return {
error: "Invalid triggers format",
details: triggersResult.error.errors.map((e) => {
const path = e.path.length > 0 ? `triggers.${e.path.join(".")}` : "triggers";
return `Field "${path}": ${e.message}`;
}),
};
}
const triggers = triggersResult.data;
// Remove these from the copy before validating against ZSurveyCreateInput
delete surveyDataCopy.languages;
delete surveyDataCopy.triggers;
// Validate the main survey structure
const surveyResult = ZSurveyCreateInput.safeParse(surveyDataCopy);
if (!surveyResult.success) {
return {
error: "Invalid survey format",
details: surveyResult.error.errors.map((e) => {
const path = e.path.length > 0 ? e.path.join(".") : "root";
return `Field "${path}": ${e.message}`;
}),
};
}
return {
surveyInput: surveyResult.data,
exportedLanguages,
triggers,
};
};

View File

@@ -0,0 +1,34 @@
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
export interface TImportCapabilities {
hasMultiLanguage: boolean;
hasFollowUps: boolean;
hasRecaptcha: boolean;
}
export const resolveImportCapabilities = async (organizationId: string): Promise<TImportCapabilities> => {
let hasMultiLanguage = false;
try {
await checkMultiLanguagePermission(organizationId);
hasMultiLanguage = true;
} catch (e) {}
let hasFollowUps = false;
try {
const organizationBilling = await getOrganizationBilling(organizationId);
if (organizationBilling) {
hasFollowUps = await getSurveyFollowUpsPermission(organizationBilling.plan);
}
} catch (e) {}
let hasRecaptcha = false;
try {
await checkSpamProtectionPermission(organizationId);
hasRecaptcha = true;
} catch (e) {}
return { hasMultiLanguage, hasFollowUps, hasRecaptcha };
};

View File

@@ -0,0 +1,34 @@
import { createId } from "@paralleldrive/cuid2";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { createSurvey } from "@/modules/survey/components/template-list/lib/survey";
import { type TMappedTrigger } from "./map-triggers";
import { type TSurveyLanguageConnection } from "./normalize-survey";
export const persistSurvey = async (
environmentId: string,
survey: TSurveyCreateInput,
newName: string,
createdBy: string,
mappedTriggers: TMappedTrigger[],
mappedLanguages?: TSurveyLanguageConnection
): Promise<{ surveyId: string }> => {
const followUpsWithNewIds = survey.followUps?.map((f) => ({
...f,
id: createId(),
surveyId: createId(),
}));
const surveyToCreate = {
...survey,
name: newName,
status: "draft" as const,
triggers: mappedTriggers as any, // Type system expects full ActionClass, but createSurvey only uses the id
followUps: followUpsWithNewIds,
createdBy,
...(mappedLanguages && { languages: mappedLanguages as any }), // Prisma nested create format
} as TSurveyCreateInput;
const newSurvey = await createSurvey(environmentId, surveyToCreate);
return { surveyId: newSurvey.id };
};

View File

@@ -15,7 +15,6 @@ export const surveySelect = {
status: true,
singleUse: true,
environmentId: true,
projectId: true,
_count: {
select: { responses: true },
},

View File

@@ -9,6 +9,7 @@ import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
import { ImportSurveyButton } from "@/modules/survey/list/components/import-survey-button";
import { SurveysList } from "@/modules/survey/list/components/survey-list";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
@@ -47,14 +48,17 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
const currentProjectChannel = project.config.channel ?? null;
const locale = (await getUserLocale(session.user.id)) ?? DEFAULT_LOCALE;
const CreateSurveyButton = () => {
const SurveyListCTA = () => {
return (
<Button size="sm" asChild>
<Link href={`/environments/${environment.id}/surveys/templates`}>
{t("environments.surveys.new_survey")}
<PlusIcon />
</Link>
</Button>
<div className="flex gap-2">
<ImportSurveyButton environmentId={environment.id} />
<Button size="sm" asChild>
<Link href={`/environments/${environment.id}/surveys/templates`}>
{t("environments.surveys.new_survey")}
<PlusIcon />
</Link>
</Button>
</div>
);
};
@@ -79,7 +83,7 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
if (surveyCount > 0) {
content = (
<>
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : <CreateSurveyButton />} />
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : <SurveyListCTA />} />
<SurveysList
environmentId={environment.id}
isReadOnly={isReadOnly}

View File

@@ -9,7 +9,6 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
name: "Minimal Survey",
type: "app",
environmentId: "someEnvId1",
projectId: null,
createdBy: null,
status: "draft",
displayOption: "displayOnce",

View File

@@ -42,6 +42,7 @@
"@lexical/react": "0.41.0",
"@lexical/rich-text": "0.41.0",
"@lexical/table": "0.41.0",
"@next-auth/prisma-adapter": "1.0.7",
"@opentelemetry/auto-instrumentations-node": "0.71.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
"@opentelemetry/exporter-prometheus": "0.213.0",

View File

@@ -485,5 +485,55 @@ test.describe("Authentication Security Tests - Vulnerability Prevention", () =>
logger.info(`✅ Malformed request handled gracefully: status ${response.status()}`);
});
test("should invalidate a copied session cookie after logout", async ({ page, browser, users }) => {
const user = await users.create();
await user.login();
const sessionCookie = (await page.context().cookies()).find((cookie) =>
cookie.name.includes("next-auth.session-token")
);
expect(sessionCookie).toBeDefined();
const preLogoutContext = await browser.newContext();
try {
await preLogoutContext.addCookies([sessionCookie!]);
const preLogoutPage = await preLogoutContext.newPage();
await preLogoutPage.goto("http://localhost:3000/environments");
await expect(preLogoutPage).not.toHaveURL(/\/auth\/login/);
} finally {
await preLogoutContext.close();
}
const signOutCsrfToken = await page
.context()
.request.get("/api/auth/csrf")
.then((response) => response.json())
.then((json) => json.csrfToken);
const signOutResponse = await page.context().request.post("/api/auth/signout", {
form: {
callbackUrl: "/auth/login",
csrfToken: signOutCsrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
expect(signOutResponse.status()).not.toBe(500);
const replayContext = await browser.newContext();
try {
await replayContext.addCookies([sessionCookie!]);
const replayPage = await replayContext.newPage();
await replayPage.goto("http://localhost:3000/environments");
await expect(replayPage).toHaveURL(/\/auth\/login/);
} finally {
await replayContext.close();
}
});
});
});

85
apps/web/proxy.test.ts Normal file
View File

@@ -0,0 +1,85 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { proxy } from "./proxy";
const { mockGetProxySession, mockIsPublicDomainConfigured, mockIsRequestFromPublicDomain } = vi.hoisted(
() => ({
mockGetProxySession: vi.fn(),
mockIsPublicDomainConfigured: vi.fn(),
mockIsRequestFromPublicDomain: vi.fn(),
})
);
vi.mock("@/modules/auth/lib/proxy-session", () => ({
getProxySession: mockGetProxySession,
}));
vi.mock("@/app/middleware/domain-utils", () => ({
isPublicDomainConfigured: mockIsPublicDomainConfigured,
isRequestFromPublicDomain: mockIsRequestFromPublicDomain,
}));
vi.mock("@/app/middleware/endpoint-validator", () => ({
isAuthProtectedRoute: (url: string) => url.startsWith("/environments"),
isRouteAllowedForDomain: vi.fn(() => true),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/utils/url", () => ({
isValidCallbackUrl: (url: string) => url.startsWith("http://localhost:3000"),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsPublicDomainConfigured.mockReturnValue(false);
mockIsRequestFromPublicDomain.mockReturnValue(false);
});
test("redirects unauthenticated protected routes to login with callbackUrl", async () => {
mockGetProxySession.mockResolvedValue(null);
const response = await proxy(new NextRequest("http://localhost:3000/environments/test"));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"http://localhost:3000/auth/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest"
);
});
test("rejects invalid callback URLs", async () => {
mockGetProxySession.mockResolvedValue(null);
const response = await proxy(
new NextRequest("http://localhost:3000/auth/login?callbackUrl=https%3A%2F%2Fevil.example")
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ error: "Invalid callback URL" });
});
test("redirects authenticated callback requests to the callback URL", async () => {
mockGetProxySession.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() + 60_000),
});
const response = await proxy(
new NextRequest(
"http://localhost:3000/auth/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest"
)
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost:3000/environments/test");
});
});

View File

@@ -1,4 +1,3 @@
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger";
@@ -6,11 +5,12 @@ import { isPublicDomainConfigured, isRequestFromPublicDomain } from "@/app/middl
import { isAuthProtectedRoute, isRouteAllowedForDomain } from "@/app/middleware/endpoint-validator";
import { WEBAPP_URL } from "@/lib/constants";
import { isValidCallbackUrl } from "@/lib/utils/url";
import { getProxySession } from "@/modules/auth/lib/proxy-session";
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
const token = await getToken({ req: request as any });
const session = await getProxySession(request);
if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) {
if (isAuthProtectedRoute(request.nextUrl.pathname) && !session) {
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
return NextResponse.redirect(loginUrl);
}
@@ -21,7 +21,7 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 });
}
if (token && callbackUrl) {
if (session && callbackUrl) {
return NextResponse.redirect(callbackUrl);
}

View File

@@ -15,7 +15,7 @@ export default defineConfig({
provider: "v8", // Use V8 as the coverage provider
reporter: ["text", "html", "lcov"], // Generate text summary and HTML reports
reportsDirectory: "./coverage", // Output coverage reports to the coverage/ directory
include: ["app/**/*.ts", "modules/**/*.ts", "lib/**/*.ts", "lingodotdev/**/*.ts"],
include: ["app/**/*.ts", "modules/**/*.ts", "lib/**/*.ts", "lingodotdev/**/*.ts", "proxy.ts"],
exclude: [
// Build and configuration files
"**/.next/**", // Next.js build output

View File

@@ -1,6 +1,6 @@
# formbricks
![Version: 0.0.0-dev](https://img.shields.io/badge/Version-0.0.0--dev-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.7.0](https://img.shields.io/badge/AppVersion-3.7.0-informational?style=flat-square)
![Version: 0.0.0-dev](https://img.shields.io/badge/Version-0.0.0--dev-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square)
A Helm chart for Formbricks with PostgreSQL, Redis
@@ -8,178 +8,150 @@ A Helm chart for Formbricks with PostgreSQL, Redis
## Maintainers
| Name | Email | Url |
| ---- | ------ | --- |
| Formbricks | <info@formbricks.com> | |
| Name | Email | Url |
| ---------- | --------------------- | --- |
| Formbricks | <info@formbricks.com> | |
## Requirements
| Repository | Name | Version |
|------------|------|---------|
| Repository | Name | Version |
| ---------------------------------------- | ---------- | ------- |
| oci://registry-1.docker.io/bitnamicharts | postgresql | 16.4.16 |
| oci://registry-1.docker.io/bitnamicharts | redis | 20.11.2 |
| oci://registry-1.docker.io/bitnamicharts | redis | 20.11.2 |
## Values
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| autoscaling.additionalLabels | object | `{}` | |
| autoscaling.annotations | object | `{}` | |
| autoscaling.behavior.scaleDown.policies[0].periodSeconds | int | `120` | |
| autoscaling.behavior.scaleDown.policies[0].type | string | `"Pods"` | |
| autoscaling.behavior.scaleDown.policies[0].value | int | `1` | |
| autoscaling.behavior.scaleDown.stabilizationWindowSeconds | int | `300` | |
| autoscaling.behavior.scaleUp.policies[0].periodSeconds | int | `60` | |
| autoscaling.behavior.scaleUp.policies[0].type | string | `"Pods"` | |
| autoscaling.behavior.scaleUp.policies[0].value | int | `2` | |
| autoscaling.behavior.scaleUp.stabilizationWindowSeconds | int | `60` | |
| autoscaling.enabled | bool | `true` | |
| autoscaling.maxReplicas | int | `10` | |
| autoscaling.metrics[0].resource.name | string | `"cpu"` | |
| autoscaling.metrics[0].resource.target.averageUtilization | int | `60` | |
| autoscaling.metrics[0].resource.target.type | string | `"Utilization"` | |
| autoscaling.metrics[0].type | string | `"Resource"` | |
| autoscaling.metrics[1].resource.name | string | `"memory"` | |
| autoscaling.metrics[1].resource.target.averageUtilization | int | `60` | |
| autoscaling.metrics[1].resource.target.type | string | `"Utilization"` | |
| autoscaling.metrics[1].type | string | `"Resource"` | |
| autoscaling.minReplicas | int | `1` | |
| componentOverride | string | `""` | |
| deployment.additionalLabels | object | `{}` | |
| deployment.additionalPodAnnotations | object | `{}` | |
| deployment.additionalPodLabels | object | `{}` | |
| deployment.affinity | object | `{}` | |
| deployment.annotations | object | `{}` | |
| deployment.args | list | `[]` | |
| deployment.command | list | `[]` | |
| deployment.containerSecurityContext.readOnlyRootFilesystem | bool | `true` | |
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
| deployment.env | object | `{}` | |
| deployment.envFrom | string | `nil` | |
| deployment.image.digest | string | `""` | |
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
| deployment.image.tag | string | `""` | |
| deployment.imagePullSecrets | string | `""` | |
| deployment.nodeSelector | object | `{}` | |
| deployment.ports.http.containerPort | int | `3000` | |
| deployment.ports.http.exposed | bool | `true` | |
| deployment.ports.http.protocol | string | `"TCP"` | |
| deployment.ports.metrics.containerPort | int | `9464` | |
| deployment.ports.metrics.exposed | bool | `true` | |
| deployment.ports.metrics.protocol | string | `"TCP"` | |
| deployment.probes.livenessProbe.failureThreshold | int | `5` | |
| deployment.probes.livenessProbe.httpGet.path | string | `"/health"` | |
| deployment.probes.livenessProbe.httpGet.port | int | `3000` | |
| deployment.probes.livenessProbe.initialDelaySeconds | int | `10` | |
| deployment.probes.livenessProbe.periodSeconds | int | `10` | |
| deployment.probes.livenessProbe.successThreshold | int | `1` | |
| deployment.probes.livenessProbe.timeoutSeconds | int | `5` | |
| deployment.probes.readinessProbe.failureThreshold | int | `5` | |
| deployment.probes.readinessProbe.httpGet.path | string | `"/health"` | |
| deployment.probes.readinessProbe.httpGet.port | int | `3000` | |
| deployment.probes.readinessProbe.initialDelaySeconds | int | `10` | |
| deployment.probes.readinessProbe.periodSeconds | int | `10` | |
| deployment.probes.readinessProbe.successThreshold | int | `1` | |
| deployment.probes.readinessProbe.timeoutSeconds | int | `5` | |
| deployment.probes.startupProbe.failureThreshold | int | `30` | |
| deployment.probes.startupProbe.periodSeconds | int | `10` | |
| deployment.probes.startupProbe.tcpSocket.port | int | `3000` | |
| deployment.reloadOnChange | bool | `false` | |
| deployment.replicas | int | `1` | |
| deployment.resources.limits.memory | string | `"2Gi"` | |
| deployment.resources.requests.cpu | string | `"1"` | |
| deployment.resources.requests.memory | string | `"1Gi"` | |
| deployment.revisionHistoryLimit | int | `2` | |
| deployment.securityContext | object | `{}` | |
| deployment.strategy.type | string | `"RollingUpdate"` | |
| deployment.tolerations | list | `[]` | |
| deployment.topologySpreadConstraints | list | `[]` | |
| enterprise.enabled | bool | `false` | |
| enterprise.licenseKey | string | `""` | |
| externalSecret.enabled | bool | `false` | |
| externalSecret.files | object | `{}` | |
| externalSecret.refreshInterval | string | `"1h"` | |
| externalSecret.secretStore.kind | string | `"ClusterSecretStore"` | |
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
| formbricks.publicUrl | string | `""` | |
| formbricks.webappUrl | string | `""` | |
| hub.enabled | bool | `true` | |
| hub.env | object | `{}` | |
| hub.existingSecret | string | `""` | |
| hub.image.pullPolicy | string | `"IfNotPresent"` | |
| hub.image.repository | string | `"ghcr.io/formbricks/hub"` | |
| hub.image.tag | string | `"1.0.0"` | |
| hub.migration.activeDeadlineSeconds | int | `900` | |
| hub.migration.backoffLimit | int | `3` | |
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
| hub.replicas | int | `1` | |
| hub.resources.limits.memory | string | `"512Mi"` | |
| hub.resources.requests.cpu | string | `"100m"` | |
| hub.resources.requests.memory | string | `"256Mi"` | |
| ingress.annotations | object | `{}` | |
| ingress.enabled | bool | `false` | |
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
| ingress.hosts[0].paths[0].path | string | `"/"` | |
| ingress.hosts[0].paths[0].pathType | string | `"Prefix"` | |
| ingress.hosts[0].paths[0].serviceName | string | `"formbricks"` | |
| ingress.ingressClassName | string | `"alb"` | |
| migration.annotations | object | `{}` | |
| migration.backoffLimit | int | `3` | |
| migration.enabled | bool | `true` | |
| migration.resources.limits.memory | string | `"512Mi"` | |
| migration.resources.requests.cpu | string | `"100m"` | |
| migration.resources.requests.memory | string | `"256Mi"` | |
| migration.ttlSecondsAfterFinished | int | `300` | |
| nameOverride | string | `""` | |
| partOfOverride | string | `""` | |
| pdb.additionalLabels | object | `{}` | |
| pdb.annotations | object | `{}` | |
| pdb.enabled | bool | `true` | |
| pdb.minAvailable | int | `1` | |
| postgresql.auth.database | string | `"formbricks"` | |
| postgresql.auth.existingSecret | string | `"formbricks-app-secrets"` | |
| postgresql.auth.secretKeys.adminPasswordKey | string | `"POSTGRES_ADMIN_PASSWORD"` | |
| postgresql.auth.secretKeys.userPasswordKey | string | `"POSTGRES_USER_PASSWORD"` | |
| postgresql.auth.username | string | `"formbricks"` | |
| postgresql.enabled | bool | `true` | |
| postgresql.externalDatabaseUrl | string | `""` | |
| postgresql.fullnameOverride | string | `"formbricks-postgresql"` | |
| postgresql.global.security.allowInsecureImages | bool | `true` | |
| postgresql.image.repository | string | `"pgvector/pgvector"` | |
| postgresql.image.tag | string | `"pg17"` | |
| postgresql.primary.containerSecurityContext.enabled | bool | `true` | |
| postgresql.primary.containerSecurityContext.readOnlyRootFilesystem | bool | `false` | |
| postgresql.primary.containerSecurityContext.runAsUser | int | `1001` | |
| postgresql.primary.networkPolicy.enabled | bool | `false` | |
| postgresql.primary.persistence.enabled | bool | `true` | |
| postgresql.primary.persistence.size | string | `"10Gi"` | |
| postgresql.primary.podSecurityContext.enabled | bool | `true` | |
| postgresql.primary.podSecurityContext.fsGroup | int | `1001` | |
| postgresql.primary.podSecurityContext.runAsUser | int | `1001` | |
| rbac.enabled | bool | `false` | |
| rbac.serviceAccount.additionalLabels | object | `{}` | |
| rbac.serviceAccount.annotations | object | `{}` | |
| rbac.serviceAccount.enabled | bool | `false` | |
| rbac.serviceAccount.name | string | `""` | |
| redis.architecture | string | `"standalone"` | |
| redis.auth.enabled | bool | `true` | |
| redis.auth.existingSecret | string | `"formbricks-app-secrets"` | |
| redis.auth.existingSecretPasswordKey | string | `"REDIS_PASSWORD"` | |
| redis.enabled | bool | `true` | |
| redis.externalRedisUrl | string | `""` | |
| redis.fullnameOverride | string | `"formbricks-redis"` | |
| redis.master.persistence.enabled | bool | `true` | |
| redis.networkPolicy.enabled | bool | `false` | |
| secret.enabled | bool | `true` | |
| service.additionalLabels | object | `{}` | |
| service.annotations | object | `{}` | |
| service.enabled | bool | `true` | |
| service.ports | list | `[]` | |
| service.type | string | `"ClusterIP"` | |
| serviceMonitor.additionalLabels | string | `nil` | |
| serviceMonitor.annotations | string | `nil` | |
| serviceMonitor.enabled | bool | `true` | |
| serviceMonitor.endpoints[0].interval | string | `"5s"` | |
| serviceMonitor.endpoints[0].path | string | `"/metrics"` | |
| serviceMonitor.endpoints[0].port | string | `"metrics"` | |
| Key | Type | Default | Description |
| ------------------------------------------------------------------ | ------ | --------------------------------- | ----------- |
| autoscaling.additionalLabels | object | `{}` | |
| autoscaling.annotations | object | `{}` | |
| autoscaling.enabled | bool | `true` | |
| autoscaling.maxReplicas | int | `10` | |
| autoscaling.metrics[0].resource.name | string | `"cpu"` | |
| autoscaling.metrics[0].resource.target.averageUtilization | int | `60` | |
| autoscaling.metrics[0].resource.target.type | string | `"Utilization"` | |
| autoscaling.metrics[0].type | string | `"Resource"` | |
| autoscaling.metrics[1].resource.name | string | `"memory"` | |
| autoscaling.metrics[1].resource.target.averageUtilization | int | `60` | |
| autoscaling.metrics[1].resource.target.type | string | `"Utilization"` | |
| autoscaling.metrics[1].type | string | `"Resource"` | |
| autoscaling.minReplicas | int | `1` | |
| componentOverride | string | `""` | |
| cronJob.enabled | bool | `false` | |
| cronJob.jobs | object | `{}` | |
| deployment.additionalLabels | object | `{}` | |
| deployment.additionalPodAnnotations | object | `{}` | |
| deployment.additionalPodLabels | object | `{}` | |
| deployment.affinity | object | `{}` | |
| deployment.annotations | object | `{}` | |
| deployment.args | list | `[]` | |
| deployment.command | list | `[]` | |
| deployment.containerSecurityContext.readOnlyRootFilesystem | bool | `true` | |
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
| deployment.env.EMAIL_VERIFICATION_DISABLED.value | string | `"1"` | |
| deployment.env.PASSWORD_RESET_DISABLED.value | string | `"1"` | |
| deployment.envFrom | string | `nil` | |
| deployment.image.digest | string | `""` | |
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
| deployment.imagePullSecrets | string | `""` | |
| deployment.nodeSelector | object | `{}` | |
| deployment.ports.http.containerPort | int | `3000` | |
| deployment.ports.http.exposed | bool | `true` | |
| deployment.ports.http.protocol | string | `"TCP"` | |
| deployment.ports.metrics.containerPort | int | `9464` | |
| deployment.ports.metrics.exposed | bool | `true` | |
| deployment.ports.metrics.protocol | string | `"TCP"` | |
| deployment.probes.livenessProbe.failureThreshold | int | `5` | |
| deployment.probes.livenessProbe.httpGet.path | string | `"/health"` | |
| deployment.probes.livenessProbe.httpGet.port | int | `3000` | |
| deployment.probes.livenessProbe.initialDelaySeconds | int | `10` | |
| deployment.probes.livenessProbe.periodSeconds | int | `10` | |
| deployment.probes.livenessProbe.successThreshold | int | `1` | |
| deployment.probes.livenessProbe.timeoutSeconds | int | `5` | |
| deployment.probes.readinessProbe.failureThreshold | int | `5` | |
| deployment.probes.readinessProbe.httpGet.path | string | `"/health"` | |
| deployment.probes.readinessProbe.httpGet.port | int | `3000` | |
| deployment.probes.readinessProbe.initialDelaySeconds | int | `10` | |
| deployment.probes.readinessProbe.periodSeconds | int | `10` | |
| deployment.probes.readinessProbe.successThreshold | int | `1` | |
| deployment.probes.readinessProbe.timeoutSeconds | int | `5` | |
| deployment.probes.startupProbe.failureThreshold | int | `30` | |
| deployment.probes.startupProbe.periodSeconds | int | `10` | |
| deployment.probes.startupProbe.tcpSocket.port | int | `3000` | |
| deployment.reloadOnChange | bool | `false` | |
| deployment.replicas | int | `1` | |
| deployment.resources.limits.memory | string | `"2Gi"` | |
| deployment.resources.requests.cpu | string | `"1"` | |
| deployment.resources.requests.memory | string | `"1Gi"` | |
| deployment.revisionHistoryLimit | int | `2` | |
| deployment.securityContext | object | `{}` | |
| deployment.strategy.type | string | `"RollingUpdate"` | |
| deployment.tolerations | list | `[]` | |
| deployment.topologySpreadConstraints | list | `[]` | |
| enterprise.enabled | bool | `false` | |
| enterprise.licenseKey | string | `""` | |
| externalSecret.enabled | bool | `false` | |
| externalSecret.files | object | `{}` | |
| externalSecret.refreshInterval | string | `"1h"` | |
| externalSecret.secretStore.kind | string | `"ClusterSecretStore"` | |
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
| ingress.annotations | object | `{}` | |
| ingress.enabled | bool | `false` | |
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
| ingress.hosts[0].paths[0].path | string | `"/"` | |
| ingress.hosts[0].paths[0].pathType | string | `"Prefix"` | |
| ingress.hosts[0].paths[0].serviceName | string | `"formbricks"` | |
| ingress.ingressClassName | string | `"alb"` | |
| nameOverride | string | `""` | |
| partOfOverride | string | `""` | |
| postgresql.auth.database | string | `"formbricks"` | |
| postgresql.auth.existingSecret | string | `"formbricks-app-secrets"` | |
| postgresql.auth.secretKeys.adminPasswordKey | string | `"POSTGRES_ADMIN_PASSWORD"` | |
| postgresql.auth.secretKeys.userPasswordKey | string | `"POSTGRES_USER_PASSWORD"` | |
| postgresql.auth.username | string | `"formbricks"` | |
| postgresql.enabled | bool | `true` | |
| postgresql.externalDatabaseUrl | string | `""` | |
| postgresql.fullnameOverride | string | `"formbricks-postgresql"` | |
| postgresql.global.security.allowInsecureImages | bool | `true` | |
| postgresql.image.repository | string | `"pgvector/pgvector"` | |
| postgresql.image.tag | string | `"0.8.0-pg17"` | |
| postgresql.primary.containerSecurityContext.enabled | bool | `true` | |
| postgresql.primary.containerSecurityContext.readOnlyRootFilesystem | bool | `false` | |
| postgresql.primary.containerSecurityContext.runAsUser | int | `1001` | |
| postgresql.primary.networkPolicy.enabled | bool | `false` | |
| postgresql.primary.persistence.enabled | bool | `true` | |
| postgresql.primary.persistence.size | string | `"10Gi"` | |
| postgresql.primary.podSecurityContext.enabled | bool | `true` | |
| postgresql.primary.podSecurityContext.fsGroup | int | `1001` | |
| postgresql.primary.podSecurityContext.runAsUser | int | `1001` | |
| rbac.enabled | bool | `false` | |
| rbac.serviceAccount.additionalLabels | object | `{}` | |
| rbac.serviceAccount.annotations | object | `{}` | |
| rbac.serviceAccount.enabled | bool | `false` | |
| rbac.serviceAccount.name | string | `""` | |
| redis.architecture | string | `"standalone"` | |
| redis.auth.enabled | bool | `true` | |
| redis.auth.existingSecret | string | `"formbricks-app-secrets"` | |
| redis.auth.existingSecretPasswordKey | string | `"REDIS_PASSWORD"` | |
| redis.enabled | bool | `true` | |
| redis.externalRedisUrl | string | `""` | |
| redis.fullnameOverride | string | `"formbricks-redis"` | |
| redis.master.persistence.enabled | bool | `true` | |
| redis.networkPolicy.enabled | bool | `false` | |
| secret.enabled | bool | `true` | |
| service.additionalLabels | object | `{}` | |
| service.annotations | object | `{}` | |
| service.enabled | bool | `true` | |
| service.ports | list | `[]` | |
| service.type | string | `"ClusterIP"` | |
| serviceMonitor.additionalLabels | string | `nil` | |
| serviceMonitor.annotations | string | `nil` | |
| serviceMonitor.enabled | bool | `true` | |
| serviceMonitor.endpoints[0].interval | string | `"5s"` | |
| serviceMonitor.endpoints[0].path | string | `"/metrics"` | |
| serviceMonitor.endpoints[0].port | string | `"metrics"` | |
---
Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2)

View File

@@ -8,15 +8,6 @@ It also truncates the name to a maximum of 63 characters and removes trailing hy
{{- end }}
{{/*
Hub resource name: base name truncated to 59 chars then "-hub" so the suffix is never lost (63 char limit).
*/}}
{{- define "formbricks.hubname" -}}
{{- $base := include "formbricks.name" . | trunc 59 | trimSuffix "-" }}
{{- printf "%s-hub" $base | trimSuffix "-" }}
{{- end }}
{{/*
Define the application version to be used in labels.
The version is taken from `.Values.deployment.image.tag` if provided, otherwise it defaults to `.Chart.Version`.
@@ -94,17 +85,9 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
{{- default .Release.Namespace .Values.namespaceOverride -}}
{{- end -}}
{{- define "formbricks.appSecretName" -}}
{{- printf "%s-app-secrets" (include "formbricks.name" .) -}}
{{- end }}
{{- define "formbricks.hubSecretName" -}}
{{- default (include "formbricks.appSecretName" .) .Values.hub.existingSecret -}}
{{- end }}
{{- define "formbricks.postgresAdminPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
{{- if and $secret (index $secret.data "POSTGRES_ADMIN_PASSWORD") }}
{{- index $secret.data "POSTGRES_ADMIN_PASSWORD" | b64dec -}}
{{- else }}
@@ -113,7 +96,7 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
{{- end }}
{{- define "formbricks.postgresUserPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
{{- if and $secret (index $secret.data "POSTGRES_USER_PASSWORD") }}
{{- index $secret.data "POSTGRES_USER_PASSWORD" | b64dec -}}
{{- else }}
@@ -122,7 +105,7 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
{{- end }}
{{- define "formbricks.redisPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
{{- if and $secret (index $secret.data "REDIS_PASSWORD") }}
{{- index $secret.data "REDIS_PASSWORD" | b64dec -}}
{{- else }}
@@ -131,7 +114,7 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
{{- end }}
{{- define "formbricks.cronSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
{{- if $secret }}
{{- index $secret.data "CRON_SECRET" | b64dec -}}
{{- else }}
@@ -140,7 +123,7 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
{{- end }}
{{- define "formbricks.encryptionKey" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
{{- if $secret }}
{{- index $secret.data "ENCRYPTION_KEY" | b64dec -}}
{{- else }}
@@ -149,19 +132,10 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
{{- end }}
{{- define "formbricks.nextAuthSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
{{- if $secret }}
{{- index $secret.data "NEXTAUTH_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.hubApiKey" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- if and $secret (index $secret.data "HUB_API_KEY") }}
{{- index $secret.data "HUB_API_KEY" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}

View File

@@ -131,10 +131,6 @@ spec:
- name: SKIP_STARTUP_MIGRATION
value: "true"
{{- end }}
{{- if not (hasKey .Values.deployment.env "HUB_API_URL") }}
- name: HUB_API_URL
value: "http://{{ include "formbricks.hubname" . }}:8080"
{{- end }}
{{- range $key, $value := .Values.deployment.env }}
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
{{- if kindIs "string" $value }}

View File

@@ -1,100 +0,0 @@
{{- if not .Values.hub.enabled }}
{{- fail "hub.enabled=false is not supported in Formbricks 5; Hub is mandatory." }}
{{- end }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "formbricks.hubname" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
replicas: {{ .Values.hub.replicas | default 1 }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub
spec:
{{- if .Values.deployment.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
initContainers:
- name: hub-migrate
image: {{ .Values.hub.image.repository }}:{{ .Values.hub.image.tag | default "latest" }}
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
command:
- sh
- -c
- |
/usr/local/bin/goose -dir /app/migrations postgres "$DATABASE_URL" up && \
/usr/local/bin/river migrate-up --database-url "$DATABASE_URL"
envFrom:
- secretRef:
name: {{ include "formbricks.hubSecretName" . }}
containers:
- name: hub
image: {{ .Values.hub.image.repository }}:{{ .Values.hub.image.tag | default "latest" }}
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
ports:
- name: http
containerPort: 8080
protocol: TCP
envFrom:
- secretRef:
name: {{ include "formbricks.hubSecretName" . }}
env:
- name: API_KEY
valueFrom:
secretKeyRef:
name: {{ include "formbricks.hubSecretName" . }}
key: HUB_API_KEY
{{- range $key, $value := .Values.hub.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- if .Values.hub.resources }}
resources:
{{- toYaml .Values.hub.resources | nindent 12 }}
{{- end }}
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 5
timeoutSeconds: 5
successThreshold: 1
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 5
timeoutSeconds: 5
successThreshold: 1
startupProbe:
httpGet:
path: /health
port: 8080
failureThreshold: 30
periodSeconds: 10

View File

@@ -1,54 +0,0 @@
{{- if not .Values.hub.enabled }}
{{- fail "hub.enabled=false is not supported in Formbricks 5; Hub is mandatory." }}
{{- end }}
---
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "formbricks.hubname" . }}-migration
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-migration
app.kubernetes.io/managed-by: {{ .Release.Service }}
annotations:
helm.sh/hook: pre-upgrade
helm.sh/hook-weight: "-5"
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
ttlSecondsAfterFinished: {{ .Values.hub.migration.ttlSecondsAfterFinished | default 300 }}
backoffLimit: {{ .Values.hub.migration.backoffLimit | default 3 }}
activeDeadlineSeconds: {{ .Values.hub.migration.activeDeadlineSeconds | default 900 }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-migration
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
{{- if .Values.deployment.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: hub-migrate
image: {{ .Values.hub.image.repository }}:{{ .Values.hub.image.tag | default "latest" }}
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
securityContext:
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
command:
- sh
- -c
- |
/usr/local/bin/goose -dir /app/migrations postgres "$DATABASE_URL" up && \
/usr/local/bin/river migrate-up --database-url "$DATABASE_URL"
envFrom:
- secretRef:
name: {{ include "formbricks.hubSecretName" . }}

View File

@@ -1,25 +0,0 @@
{{- if not .Values.hub.enabled }}
{{- fail "hub.enabled=false is not supported in Formbricks 5; Hub is mandatory." }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "formbricks.hubname" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
ports:
- name: http
port: 8080
targetPort: 8080
protocol: TCP

View File

@@ -4,13 +4,11 @@
{{- $postgresUserPassword := include "formbricks.postgresUserPassword" . }}
{{- $redisPassword := include "formbricks.redisPassword" . }}
{{- $webappUrl := required "formbricks.webappUrl is required. Set it to your Formbricks instance URL (e.g., https://formbricks.example.com)" .Values.formbricks.webappUrl }}
{{- $hubApiKey := include "formbricks.hubApiKey" . }}
{{- $includeHubApiKeyInAppSecret := or (not .Values.hub.existingSecret) (eq .Values.hub.existingSecret (include "formbricks.appSecretName" .)) }}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ include "formbricks.appSecretName" . }}
name: {{ template "formbricks.name" . }}-app-secrets
labels:
{{- include "formbricks.labels" . | nindent 4 }}
data:
@@ -30,9 +28,6 @@ data:
{{- else }}
DATABASE_URL: {{ .Values.postgresql.externalDatabaseUrl | b64enc }}
{{- end }}
{{- if $includeHubApiKeyInAppSecret }}
HUB_API_KEY: {{ $hubApiKey | b64enc }}
{{- end }}
CRON_SECRET: {{ include "formbricks.cronSecret" . | b64enc }}
ENCRYPTION_KEY: {{ include "formbricks.encryptionKey" . | b64enc }}
NEXTAUTH_SECRET: {{ include "formbricks.nextAuthSecret" . | b64enc }}

View File

@@ -340,43 +340,6 @@ serviceMonitor:
path: /metrics
port: metrics
##########################################################
# Hub API Configuration
# Formbricks Hub image: ghcr.io/formbricks/hub
##########################################################
hub:
# Hub is mandatory in Formbricks 5. Keep this enabled.
enabled: true
replicas: 1
image:
repository: "ghcr.io/formbricks/hub"
# Pin to a semver tag for reproducible deployments; update on each Hub release.
tag: "1.0.0"
pullPolicy: IfNotPresent
# Optional override for the secret Hub reads from.
# Defaults to the generated app secret (<release>-app-secrets), which contains DATABASE_URL and HUB_API_KEY.
# If you set this, the custom secret must provide DATABASE_URL and HUB_API_KEY.
existingSecret: ""
# Optional env vars (non-secret). Use existingSecret for secret values such as DATABASE_URL and HUB_API_KEY.
env: {}
# Upgrade migration job runs goose + river before Helm upgrades Hub resources.
# Fresh installs run the same migrations through the Hub deployment init container.
migration:
ttlSecondsAfterFinished: 300
backoffLimit: 3
activeDeadlineSeconds: 900
resources:
limits:
memory: 512Mi
requests:
memory: 256Mi
cpu: "100m"
##########################################################
# PostgreSQL Configuration
##########################################################
@@ -389,7 +352,7 @@ postgresql:
fullnameOverride: "formbricks-postgresql"
image:
repository: pgvector/pgvector
tag: pg17
tag: 0.8.0-pg17
auth:
username: formbricks
database: formbricks

View File

@@ -1,24 +1,14 @@
services:
# PostgreSQL must load the vector library so Hub (and Formbricks) can use the pgvector extension.
postgres:
image: pgvector/pgvector:pg18
image: pgvector/pgvector:pg17
volumes:
- postgres:/var/lib/postgresql
- postgres:/var/lib/postgresql/data
environment:
- POSTGRES_DB=postgres
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
ports:
- 5432:5432
command: >
postgres
-c shared_preload_libraries=vector
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres || exit 1"]
interval: 5s
timeout: 3s
retries: 30
start_period: 10s
mailhog:
image: arjenz/mailhog
@@ -46,40 +36,6 @@ services:
volumes:
- minio-data:/data
# Run Hub DB migrations (goose + river) before the API starts. Idempotent; runs on every compose up.
hub-migrate:
image: ghcr.io/formbricks/hub:latest
restart: "no"
entrypoint: ["sh", "-c"]
command:
[
'if [ -x /usr/local/bin/goose ] && [ -x /usr/local/bin/river ]; then /usr/local/bin/goose -dir /app/migrations postgres "$$DATABASE_URL" up && /usr/local/bin/river migrate-up --database-url "$$DATABASE_URL"; else echo ''Migration tools (goose/river) not in image.''; exit 1; fi',
]
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
depends_on:
postgres:
condition: service_healthy
# Formbricks Hub API (ghcr.io/formbricks/hub). Shares the same Postgres database as Formbricks by default.
hub:
image: ghcr.io/formbricks/hub:latest
depends_on:
hub-migrate:
condition: service_completed_successfully
ports:
- "8080:8080"
environment:
API_KEY: ${HUB_API_KEY:-dev-api-key}
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
# Explicit Postgres env so migrations and any libpq fallback use the service host, not localhost
PGHOST: postgres
PGPORT: "5432"
PGUSER: postgres
PGPASSWORD: postgres
PGDATABASE: postgres
PGSSLMODE: disable
volumes:
postgres:
driver: local

View File

@@ -27,13 +27,3 @@ The script will prompt you for the following information:
3. **Domain Name**: Enter the domain name that Traefik will use to create the SSL certificate and forward requests to Formbricks.
That's it! After running the command and providing the required information, visit the domain name you entered, and you should see the Formbricks home wizard!
## Formbricks Hub
The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (`ghcr.io/formbricks/hub`). Hub shares the same database as Formbricks by default.
- **Migrations**: A `hub-migrate` service runs Hub's database migrations (goose + river) before the Hub API starts. It runs on every `docker compose up` and is idempotent.
- **Production** (`docker/docker-compose.yml`): Set `HUB_API_KEY` (required). `HUB_API_URL` defaults to `http://hub:8080` so the Formbricks app can reach Hub inside the compose network. Override `HUB_DATABASE_URL` only if you want Hub to use a separate database.
- **Development** (`docker-compose.dev.yml`): Hub uses the same Postgres database; `HUB_API_KEY` defaults to `dev-api-key` (override with `HUB_API_KEY`) and the local Hub URL is `http://localhost:8080`.
In development, Hub is exposed locally on port **8080**. In production Docker Compose, Hub stays internal to the compose network and is reached via `http://hub:8080`.

View File

@@ -29,15 +29,6 @@ x-environment: &environment
# To use external Redis/Valkey: remove the redis service below and update this URL
REDIS_URL: redis://redis:6379
# Formbricks Hub (port 8080): API key required. Use e.g. openssl rand -hex 32
HUB_API_KEY:
# Base URL the Formbricks app uses to reach Hub. Defaults to the internal Hub service.
HUB_API_URL: ${HUB_API_URL:-http://hub:8080}
# Hub database URL (optional). Default: same Postgres as Formbricks. Set only if Hub uses a separate DB.
# HUB_DATABASE_URL:
# Set the minimum log level(debug, info, warn, error, fatal)
# LOG_LEVEL: info
@@ -211,7 +202,7 @@ x-environment: &environment
services:
postgres:
restart: always
image: pgvector/pgvector:pg18
image: pgvector/pgvector:pg17
volumes:
- postgres:/var/lib/postgresql/data
environment:
@@ -254,31 +245,6 @@ services:
- ./saml-connection:/home/nextjs/apps/web/saml-connection
<<: *environment
# Run Hub DB migrations (goose + river) before the API starts. Uses same image; migrations are idempotent.
hub-migrate:
image: ghcr.io/formbricks/hub:latest
restart: "no"
entrypoint: ["sh", "-c"]
command: ["if [ -x /usr/local/bin/goose ] && [ -x /usr/local/bin/river ]; then /usr/local/bin/goose -dir /app/migrations postgres \"$$DATABASE_URL\" up && /usr/local/bin/river migrate-up --database-url \"$$DATABASE_URL\"; else echo 'Migration tools (goose/river) not in image.'; exit 1; fi"]
environment:
DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/formbricks?sslmode=disable}
depends_on:
postgres:
condition: service_healthy
# Formbricks Hub API (ghcr.io/formbricks/hub). Set HUB_API_KEY. By default shares the Formbricks database; set HUB_DATABASE_URL to use a separate DB.
hub:
restart: always
image: ghcr.io/formbricks/hub:latest
depends_on:
hub-migrate:
condition: service_completed_successfully
postgres:
condition: service_healthy
environment:
API_KEY: ${HUB_API_KEY:?HUB_API_KEY is required to run Hub}
DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/formbricks?sslmode=disable}
volumes:
postgres:
driver: local

View File

@@ -1,275 +0,0 @@
# Plan: Deprecate Environments in Formbricks
**Issue**: https://github.com/formbricks/internal/issues/1501
## Context
Formbricks currently has a 4-level hierarchy: **Organization → Project → Environment (prod/dev) → Resources**. The "Environment" layer adds complexity with minimal value — the only real difference between prod and dev is separate API keys and a UI badge. The UI already calls "Project" a "Workspace".
**Goal**: Collapse the Environment layer so resources live directly under Project. The production environment merges into the workspace identity. Dev environments with data become separate new workspaces.
**Key decisions**:
- DB model stays as `Project` (no table rename)
- SDK will accept `workspaceId` as new param, `environmentId` as deprecated alias
- Dev environments with data get promoted to separate workspaces
---
## Current State
```
Organization
└── Project ("Workspace" in UI)
├── Environment (production) ──→ surveys, contacts, webhooks, tags, ...
└── Environment (development) ──→ surveys, contacts, webhooks, tags, ...
```
Every project always has exactly 2 environments. The only differences between them:
- Separate data (contacts, responses, attributes, integrations, webhooks, segments, etc.)
- Separate API keys (`ApiKeyEnvironment` grants per-environment permissions)
- A red warning banner in the dev UI, plus an environment switcher breadcrumb
Key metrics:
- **564 files** in `apps/web` reference `environmentId`
- **52 files** in `packages` reference `environmentId`
- **68+ route directories** under `/environments/[environmentId]/`
- **22 API endpoint directories** keyed by `[environmentId]`
- **8 resource tables** FK to Environment: `Survey`, `Contact`, `ActionClass`, `ContactAttributeKey`, `Webhook`, `Tag`, `Segment`, `Integration`
- **SDK** requires `environmentId` to initialize, all client APIs use `/api/v1/client/[environmentId]/...`
- **Storage** paths: `private/${environmentId}/${fileName}`
---
## Phase 1: Add `projectId` Column to All Environment-Owned Models (PR 1 — Small, Low Risk)
Add an **optional** `projectId` column alongside the existing `environmentId` on every model that currently only references Environment.
**Why**: Today, Survey has `environmentId` pointing to Environment, and you have to join through Environment to reach Project. We need Survey to point directly to Project. But we can't just switch the FK in one shot — that would break everything. So we add a new nullable `projectId` column alongside the existing `environmentId`. No code changes, no runtime impact. Just schema preparation.
**Modify**: `packages/database/schema.prisma`
- Add `projectId String?` + FK to Project + index to: `Survey`, `Contact`, `ActionClass`, `ContactAttributeKey`, `Webhook`, `Tag`, `Segment`, `Integration`
- Add reverse relations on the `Project` model
- New Prisma migration file
No code changes. No runtime behavior change. All new columns are NULL.
---
## Phase 2: Backfill `projectId` (PR 2 — Small, Medium Risk)
Data migration to populate `projectId` on every existing row.
**Why**: The new `projectId` columns are all NULL. We need to populate them by joining through the Environment table: `Survey.environmentId → Environment.id → Environment.projectId`. After this, every row has both `environmentId` (old) and `projectId` (new) filled in, but the app still only reads `environmentId`.
```sql
UPDATE "Survey" s SET "projectId" = e."projectId"
FROM "Environment" e WHERE s."environmentId" = e."id" AND s."projectId" IS NULL;
-- Repeat for all 8 tables
```
**Create**: Migration script (idempotent — only updates rows where `projectId IS NULL`)
App behavior unchanged. New columns now populated but not yet read.
---
## Phase 3: Dual-Write (PR 3 — Large, Medium Risk)
All create/update operations write both `environmentId` AND `projectId`.
**Why**: New rows created after the backfill would still have `projectId = NULL` because the app code doesn't know about the new column yet. We update every `prisma.survey.create(...)`, `prisma.contact.create(...)`, etc. to write both `environmentId` and `projectId`. Now every new row gets both values. Old code still reads `environmentId` — nothing breaks.
**Key files to modify**:
- `apps/web/lib/survey/service.ts``createSurvey`
- `apps/web/lib/environment/service.ts``createEnvironment` (creates default ContactAttributeKeys)
- `apps/web/modules/projects/settings/lib/project.ts``createProject`
- `apps/web/modules/survey/list/lib/survey.ts``copySurveyToOtherEnvironment`
- `apps/web/modules/survey/components/template-list/lib/survey.ts``createSurvey`
- `apps/web/lib/actionClass/service.ts``createActionClass`
- `apps/web/modules/survey/editor/lib/action-class.ts``createActionClass`
- `apps/web/modules/ee/contacts/lib/contacts.ts``processCsvRecord`, `createMissingAttributeKeys`
- `apps/web/modules/ee/contacts/api/v2/management/contacts/lib/contact.ts``createContact`
- `apps/web/app/api/v1/client/[environmentId]/displays/lib/display.ts``createDisplay` (creates contacts)
- `apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts``createContactAttributeKey`
- `apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts``createContactAttributeKey`
- `apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts``createContactAttributeKey`
- `apps/web/modules/integrations/webhooks/lib/webhook.ts``createWebhook`
- `apps/web/modules/api/v2/management/webhooks/lib/webhook.ts``createWebhook`
- `apps/web/app/api/v1/webhooks/lib/webhook.ts``createWebhook`
- `apps/web/lib/tag/service.ts``createTag`
- `apps/web/modules/ee/contacts/segments/lib/segments.ts``createSegment`, `cloneSegment`, `resetSegmentInSurvey`
- `apps/web/lib/integration/service.ts``createOrUpdateIntegration`
Pattern:
```typescript
// Resolve environmentId to projectId using existing getEnvironment()
const environment = await getEnvironment(environmentId);
const projectId = environment.projectId;
await prisma.survey.create({ data: { environmentId, projectId, ...rest } });
```
---
## Phase 4: Switch Internal Reads to `projectId` (PR 4 — Very Large, High Risk)
Change internal (non-API) queries from `WHERE environmentId = ?` to `WHERE projectId = ?`.
**Why**: This is the actual migration. Every query that says `WHERE environmentId = X` changes to `WHERE projectId = X`. Functions like `getSurveys(environmentId)` become `getSurveys(projectId)`. The layout at `/environments/[environmentId]/layout.tsx` resolves the environmentId from the URL to a projectId early on and passes projectId downstream. After this phase, the app internally thinks in terms of projects, not environments, even though URLs still say `[environmentId]`.
**Key files**:
- `apps/web/modules/survey/list/lib/survey.ts``getSurveys(environmentId)``getSurveys(projectId)`
- `apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts``getEnvironmentStateData`
- `apps/web/modules/environments/lib/utils.ts``getEnvironmentAuth`, `getEnvironmentLayoutData`
- `apps/web/app/(app)/environments/[environmentId]/layout.tsx` — resolve `projectId` early, pass to context
- `apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx` — add `projectId`
- All page server components that pass `environmentId` to service functions
URL still has `[environmentId]`. Each page resolves `environmentId → projectId` at the top.
**This PR can be split further** by migrating one resource type at a time (surveys first, then contacts, then actions, etc.).
---
## Phase 5: Client API Backwards Compatibility (PR 5 — Medium, Medium Risk)
Make `/api/v1/client/[environmentId]/...` and `/api/v2/client/[environmentId]/...` accept either an `environmentId` or a `projectId`.
**Why**: The SDK sends requests to `/api/v1/client/[environmentId]/...`. Existing deployed SDKs will keep sending environmentIds. New SDKs will send projectIds. Each route handler needs to accept either and resolve to a projectId internally. This ensures old SDK versions don't break.
**Add fallback resolution at top of each route handler**:
```typescript
// Try Environment table first, fall back to Project table
let projectId: string;
const environment = await prisma.environment.findUnique({ where: { id: params.environmentId } });
if (environment) {
projectId = environment.projectId;
} else {
projectId = params.environmentId; // caller passed a projectId directly
}
```
**Files**:
- `apps/web/app/api/v1/client/[environmentId]/environment/route.ts`
- `apps/web/app/api/v1/client/[environmentId]/displays/route.ts`
- `apps/web/app/api/v1/client/[environmentId]/responses/route.ts`
- `apps/web/app/api/v1/client/[environmentId]/storage/route.ts`
- `apps/web/app/api/v1/client/[environmentId]/user/route.ts`
- `apps/web/app/api/v2/client/[environmentId]/` — all routes
---
## Phase 6: Management API + API Key Migration (PR 6 — Medium, Medium Risk)
**Why**: The `ApiKeyEnvironment` model grants per-environment permissions. API keys used by integrations (Zapier, Make, etc.) reference environmentIds. These need to work at the project level. The management API endpoints that accept `environmentId` in request bodies need to also accept `projectId`.
- Modify `ApiKeyEnvironment` to also support project-level permissions (or add `projectId` to the model)
- Update `apps/web/app/api/v1/auth.ts``authenticateRequest` resolves environment permissions to project
- Management route handlers accept `environmentId` OR `projectId` in request bodies
- API key management UI in `modules/organization/settings/api-keys/`
---
## Phase 7: Storage Path Migration (PR 7 — Medium, Medium Risk)
**Why**: Uploaded files are stored at paths like `private/{environmentId}/{fileName}`. New uploads should use `{projectId}/...`, but old files still live at the old paths. Downloads need to check both locations for backward compatibility.
- New uploads use `{projectId}/{accessType}/{fileName}`
- Downloads check both `{projectId}/...` and `{environmentId}/...` paths for backwards compat
- `apps/web/modules/storage/service.ts`
- `apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts`
---
## Phase 8: Dev Environment Data Migration (PR 8 — Large, High Risk)
**Why**: Currently each project has a prod and dev environment. After the migration, there's no "environment" concept — just projects. Dev environments with no data can be discarded. Dev environments with data need to be promoted into new standalone projects so that data isn't lost.
For each Project with a development Environment that has data:
1. Create new Project named `{name} (Dev)` in the same Organization
2. Create a production Environment for the new Project
3. Re-parent all dev environment resources to the new Project (update `projectId`)
4. Re-parent resources to the new production environment (update `environmentId`)
For development environments with NO data: leave as-is (will be cleaned up later).
**Create**: Idempotent migration script in `packages/database/migration/` or `scripts/`
---
## Phase 9: New `/workspaces/[projectId]/` Routes + Redirects (PR 9 — Very Large, High Risk)
**Why**: The URL currently says `/environments/[environmentId]/surveys/...`. After the migration, it should say `/workspaces/[projectId]/surveys/...`. This phase creates the new route group mirroring the old structure, removes the environment switcher breadcrumb, and adds redirects so old bookmarked URLs still work.
- Create `/apps/web/app/(app)/workspaces/[projectId]/` route group mirroring the environments structure
- New layout resolves `projectId` directly
- Old `/environments/[environmentId]/...` routes redirect to `/workspaces/{projectId}/...`
- Update `apps/web/app/page.tsx` to redirect to workspace URLs
- Remove environment switcher breadcrumb
**Can be split** into sub-PRs: layout first, then surveys, then settings, etc.
---
## Phase 10: Make `projectId` NOT NULL (PR 10 — Small, Low Risk)
**Why**: At this point, every row has `projectId` populated (backfill + dual-write), and all reads use `projectId`. Now we can safely make it required in the schema. This is a safety net — the DB will reject any row that somehow doesn't have a projectId.
```sql
ALTER TABLE "Survey" ALTER COLUMN "projectId" SET NOT NULL;
-- Repeat for all 8 tables
```
Pre-check: verify no NULL values remain.
---
## Phase 11: JS SDK Update (PR 11 — Medium, Low Risk)
**Why**: Add `workspaceId` as the new init parameter. `environmentId` keeps working as a deprecated alias. Existing integrations don't break.
- `packages/js-core/src/types/config.ts` — add `workspaceId` to `TConfigInput`
- `packages/js-core/src/lib/common/setup.ts` — accept `workspaceId`, fall back to `environmentId`
- `environmentId` continues working as deprecated alias indefinitely
```typescript
// New:
formbricks.init({ workspaceId: "cxxx", appUrl: "..." })
// Old (still works):
formbricks.init({ environmentId: "cxxx", appUrl: "..." })
```
---
## Verification
After each PR:
1. `pnpm build` passes
2. Existing tests pass (`pnpm test`)
3. Manual smoke test: create survey, submit response, check dashboard
4. SDK initialization works with existing `environmentId`
After full migration:
- Old environment URLs redirect correctly
- Old API keys work
- Old SDK `environmentId` init works
- New `workspaceId` SDK init works
- Storage files accessible via both old and new paths
- Dev environments with data are separate workspaces
---
## PR Summary
| PR | Phase | Description | Size | Risk |
|----|-------|-------------|------|------|
| 1 | 1 | Add nullable `projectId` columns | S | Low |
| 2 | 2 | Backfill `projectId` data migration | S | Med |
| 3 | 3 | Dual-write `projectId` on all creates | L | Med |
| 4 | 4 | Switch reads to `projectId` | XL | High |
| 5 | 5 | Client API backwards compat | M | Med |
| 6 | 6 | Management API + API key migration | M | Med |
| 7 | 7 | Storage path migration | M | Med |
| 8 | 8 | Dev environment → workspace promotion | L | High |
| 9 | 9 | New workspace routes + redirects | XL | High |
| 10 | 10 | Make `projectId` NOT NULL | S | Low |
| 11 | 11 | JS SDK `workspaceId` support | M | Low |

View File

@@ -32,6 +32,7 @@ These variables are present inside your machine's docker-compose file. Restart t
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to 1. | optional | |
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
| MAIL_FROM_NAME | Email name/title to send emails from. | optional (required if email services are to be enabled) | |
@@ -77,14 +78,4 @@ These variables are present inside your machine's docker-compose file. Restart t
| AUDIT_LOG_ENABLED | Set this to 1 to enable audit logging. Requires Redis to be configured with the REDIS_URL env variable. | optional | 0 |
| AUDIT_LOG_GET_USER_IP | Set to 1 to include user IP addresses in audit logs from request headers | optional | 0 |
#### Formbricks Hub
When running the stack with [Formbricks Hub](https://github.com/formbricks/hub) (for example via Docker Compose or Helm), the following variables apply:
| Variable | Description | Required | Default |
| ---------------- | ------------------------------------------------------------------------------------------------ | -------------------------- | ----------------------------------------------------- |
| HUB_API_KEY | API key used by the Formbricks Hub API (port 8080). | required | (e.g. `openssl rand -hex 32`) |
| HUB_API_URL | Base URL the Formbricks app uses to call Hub. Use `http://localhost:8080` locally. | required | `http://localhost:8080` in local dev |
| HUB_DATABASE_URL | PostgreSQL connection URL for Hub. Omit to use the same database as Formbricks. | optional | Same as Formbricks `DATABASE_URL` (shared database) |
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we'll try our best to work out a solution with you.

View File

@@ -70,6 +70,18 @@ endpoint with [ngrok](https://ngrok.com/docs/universal-gateway/http).
workflow while validating the webhook setup.
</Note>
### Allowing Internal URLs (Self-Hosted Only)
By default, Formbricks blocks webhook URLs that point to private or internal IP addresses (e.g. `localhost`, `192.168.x.x`, `10.x.x.x`) to prevent [SSRF attacks](https://owasp.org/www-community/attacks/Server-Side_Request_Forgery). If you are self-hosting Formbricks and need to send webhooks to internal services, you can set the following environment variable:
```sh
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
```
<Warning>
Only enable this on trusted, self-hosted environments. Enabling this on a publicly accessible instance exposes your server to SSRF risks.
</Warning>
If you encounter any issues or need help setting up webhooks, feel free to reach out to us on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions). 😃
---

View File

@@ -47,9 +47,7 @@ export const xmSegmentMigration: MigrationScript = {
id: "s644oyyqccstfdeejc4fluye",
name: "20241209110456_xm_segment_migration",
run: async ({ tx }) => {
const allSegments = await tx.segment.findMany({
select: { id: true, filters: true },
});
const allSegments = await tx.segment.findMany();
const updationPromises = [];
for (const segment of allSegments) {
updationPromises.push(
@@ -58,7 +56,6 @@ export const xmSegmentMigration: MigrationScript = {
data: {
filters: findAndReplace(segment.filters),
},
select: { id: true },
})
);
}

View File

@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,80 +0,0 @@
-- AlterTable
ALTER TABLE "ActionClass" ADD COLUMN "projectId" TEXT;
-- AlterTable
ALTER TABLE "Contact" ADD COLUMN "projectId" TEXT;
-- AlterTable
ALTER TABLE "ContactAttributeKey" ADD COLUMN "projectId" TEXT;
-- AlterTable
ALTER TABLE "Integration" ADD COLUMN "projectId" TEXT;
-- AlterTable
ALTER TABLE "Segment" ADD COLUMN "projectId" TEXT;
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "projectId" TEXT;
-- AlterTable
ALTER TABLE "Tag" ADD COLUMN "projectId" TEXT;
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "projectId" TEXT;
-- AlterTable
ALTER TABLE "ApiKeyEnvironment" ADD COLUMN "projectId" TEXT;
-- CreateIndex
CREATE INDEX "ActionClass_projectId_createdAt_idx" ON "ActionClass"("projectId", "created_at");
-- CreateIndex
CREATE INDEX "Contact_projectId_idx" ON "Contact"("projectId");
-- CreateIndex
CREATE INDEX "ContactAttributeKey_projectId_createdAt_idx" ON "ContactAttributeKey"("projectId", "created_at");
-- CreateIndex
CREATE INDEX "Integration_projectId_idx" ON "Integration"("projectId");
-- CreateIndex
CREATE INDEX "Segment_projectId_idx" ON "Segment"("projectId");
-- CreateIndex
CREATE INDEX "Survey_projectId_updatedAt_idx" ON "Survey"("projectId", "updated_at");
-- CreateIndex
CREATE INDEX "Tag_projectId_idx" ON "Tag"("projectId");
-- CreateIndex
CREATE INDEX "Webhook_projectId_idx" ON "Webhook"("projectId");
-- CreateIndex
CREATE INDEX "ApiKeyEnvironment_projectId_idx" ON "ApiKeyEnvironment"("projectId");
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContactAttributeKey" ADD CONSTRAINT "ContactAttributeKey_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Contact" ADD CONSTRAINT "Contact_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ActionClass" ADD CONSTRAINT "ActionClass_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Integration" ADD CONSTRAINT "Integration_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Segment" ADD CONSTRAINT "Segment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiKeyEnvironment" ADD CONSTRAINT "ApiKeyEnvironment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -49,14 +49,11 @@ model Webhook {
source WebhookSource @default(user)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String?
triggers PipelineTriggers[]
surveyIds String[]
secret String?
@@index([environmentId])
@@index([projectId])
}
/// Represents an attribute value associated with a contact.
@@ -119,14 +116,11 @@ model ContactAttributeKey {
dataType ContactAttributeDataType @default(string)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String?
attributes ContactAttribute[]
attributeFilters SurveyAttributeFilter[]
@@unique([key, environmentId])
@@index([environmentId, createdAt])
@@index([projectId, createdAt])
}
/// Represents a person or user who can receive and respond to surveys.
@@ -143,14 +137,11 @@ model Contact {
updatedAt DateTime @updatedAt @map(name: "updated_at")
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String?
responses Response[]
attributes ContactAttribute[]
displays Display[]
@@index([environmentId])
@@index([projectId])
}
/// Stores a user's response to a survey, including their answers and metadata.
@@ -213,11 +204,8 @@ model Tag {
responses TagsOnResponses[]
environmentId String
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String?
@@unique([environmentId, name])
@@index([projectId])
}
/// Junction table linking tags to responses.
@@ -362,8 +350,6 @@ model Survey {
type SurveyType @default(app)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String?
creator User? @relation(fields: [createdBy], references: [id])
createdBy String?
status SurveyStatus @default(draft)
@@ -427,7 +413,6 @@ model Survey {
@@index([environmentId, updatedAt])
@@index([segmentId])
@@index([projectId, updatedAt])
}
/// Represents a quota configuration for a survey.
@@ -522,14 +507,11 @@ model ActionClass {
noCodeConfig Json?
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String?
surveyTriggers SurveyTrigger[]
@@unique([key, environmentId])
@@unique([name, environmentId])
@@index([environmentId, createdAt])
@@index([projectId, createdAt])
}
enum EnvironmentType {
@@ -558,12 +540,9 @@ model Integration {
/// [IntegrationConfig]
config Json
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String?
@@unique([type, environmentId])
@@index([environmentId])
@@index([projectId])
}
enum DataMigrationStatus {
@@ -669,17 +648,6 @@ model Project {
projectTeams ProjectTeam[]
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
// Direct resource relations (for environment deprecation migration)
surveys Survey[]
contacts Contact[]
actionClasses ActionClass[]
contactAttributeKeys ContactAttributeKey[]
webhooks Webhook[]
tags Tag[]
segments Segment[]
integrations Integration[]
apiKeyEnvironments ApiKeyEnvironment[]
@@unique([organizationId, name])
}
@@ -839,13 +807,10 @@ model ApiKeyEnvironment {
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
environmentId String
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String?
permission ApiKeyPermission
@@unique([apiKeyId, environmentId])
@@index([environmentId])
@@index([projectId])
}
enum IdentityProvider {
@@ -888,6 +853,32 @@ model Account {
@@index([userId])
}
/// Stores active authentication sessions for revocable server-side login state.
///
/// @property sessionToken - Opaque token stored in the browser cookie
/// @property user - The Formbricks user who owns this session
/// @property expires - Hard expiry for the session
model Session {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
sessionToken String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
expires DateTime
@@index([userId])
}
/// Stores one-time verification tokens used by Auth.js adapter flows.
model VerificationToken {
identifier String
token String
expires DateTime
@@unique([identifier, token])
}
/// Represents a user in the Formbricks system.
/// Central model for user authentication and profile management.
///
@@ -913,6 +904,7 @@ model User {
identityProviderAccountId String?
memberships Membership[]
accounts Account[]
sessions Session[]
groupId String?
invitesCreated Invite[] @relation("inviteCreatedBy")
invitesAccepted Invite[] @relation("inviteAcceptedBy")
@@ -945,12 +937,9 @@ model Segment {
filters Json @default("[]")
environmentId String
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String?
surveys Survey[]
@@unique([environmentId, title])
@@index([projectId])
}
/// Represents a supported language in the system.

View File

@@ -10,7 +10,7 @@ export const ZApiKeyEnvironment = z.object({
updatedAt: z.date(),
apiKeyId: z.cuid2(),
environmentId: z.cuid2(),
projectId: z.cuid2().nullable(),
projectId: z.cuid2(),
projectName: z.string(),
environmentType: z.enum(EnvironmentType),
permission: ZApiKeyPermission,

View File

@@ -54,7 +54,6 @@ export const ZContactAttributeKey = z.object({
})
.describe("The data type of the attribute (string, number, date)"),
environmentId: z.cuid2().describe("The ID of the environment this attribute belongs to"),
projectId: z.string().nullable().describe("The ID of the project this attribute belongs to"),
}) satisfies z.ZodType<ContactAttributeKey>;
ZContactAttributeKey.meta({

View File

@@ -17,7 +17,6 @@ export const ZContact = z.object({
})
.describe("When the contact was last updated"),
environmentId: z.string().describe("The environment this contact belongs to"),
projectId: z.string().nullable().describe("The project this contact belongs to"),
}) satisfies z.ZodType<Contact>;
ZContact.meta({

View File

@@ -72,7 +72,6 @@ const ZSurveyBase = z.object({
pin: z.string().nullable().describe("The pin of the survey"),
createdBy: z.string().nullable().describe("The user who created the survey"),
environmentId: z.cuid2().describe("The environment ID of the survey"),
projectId: z.string().nullable().describe("The project ID of the survey"),
questions: z.array(ZSurveyQuestion).describe("The questions of the survey"),
blocks: ZSurveyBlocks.prefault([]).describe("The blocks of the survey"),
endings: z.array(ZSurveyEnding).prefault([]).describe("The endings of the survey"),

View File

@@ -19,7 +19,6 @@ export const ZWebhook = z.object({
url: z.url().describe("The URL of the webhook"),
source: z.enum(["user", "zapier", "make", "n8n"]).describe("The source of the webhook"),
environmentId: z.cuid2().describe("The ID of the environment"),
projectId: z.string().nullable().describe("The ID of the project"),
triggers: z
.array(z.enum(["responseFinished", "responseCreated", "responseUpdated"]))
.describe("The triggers of the webhook")

View File

@@ -62,7 +62,6 @@ export const mockSurvey: TEnvironmentStateSurvey = {
createdAt: new Date("2025-01-01T10:00:00Z"),
updatedAt: new Date("2025-01-01T10:00:00Z"),
environmentId: mockEnvironmentId,
projectId: null,
description: "Manual Trigger",
noCodeConfig: {
elementSelector: { cssSelector: ".btn", innerHtml: "Click me" },

View File

@@ -0,0 +1,85 @@
{
"common": {
"and": "ja",
"apply": "rakenda",
"auto_close_wrapper": "Automaatse sulgemise ümbris",
"back": "Tagasi",
"close_survey": "Sulge küsitlus",
"company_logo": "Ettevõtte logo",
"finish": "Lõpeta",
"language_switch": "Keele vahetamine",
"next": "Edasi",
"no_results_found": "Tulemusi ei leitud",
"open_in_new_tab": "Ava uuel vahelehel",
"people_responded": "{count, plural, one {1 inimene vastas} other {{count} inimest vastas}}",
"please_retry_now_or_try_again_later": "Palun proovi uuesti kohe või hiljem.",
"powered_by": "Teenust pakub",
"privacy_policy": "Privaatsuspoliitika",
"protected_by_reCAPTCHA_and_the_Google": "Kaitstud reCAPTCHA ja Google'i poolt",
"question": "Küsimus",
"question_video": "Küsimuse video",
"required": "Kohustuslik",
"respondents_will_not_see_this_card": "Vastajad ei näe seda kaarti",
"retry": "Proovi uuesti",
"retrying": "Proovin uuesti…",
"search": "Otsi...",
"select_option": "Vali variant",
"select_options": "Vali variandid",
"sending_responses": "Vastuste saatmine…",
"takes_less_than_x_minutes": "{count, plural, one {Võtab vähem kui 1 minuti} other {Võtab vähem kui {count} minutit}}",
"takes_x_minutes": "{count, plural, one {Võtab 1 minuti} other {Võtab {count} minutit}}",
"takes_x_plus_minutes": "Võtab {count}+ minutit",
"terms_of_service": "Teenusetingimused",
"the_servers_cannot_be_reached_at_the_moment": "Serveritega ei saa hetkel ühendust.",
"they_will_be_redirected_immediately": "Nad suunatakse kohe ümber",
"your_feedback_is_stuck": "Sinu tagasiside on kinni jäänud :("
},
"errors": {
"all_options_must_be_ranked": "Palun järjesta kõik variandid",
"all_rows_must_be_answered": "Palun vasta kõikidele ridadele",
"file_extension_must_be": "Faililaiend peab olema {extension}",
"file_extension_must_not_be": "Faililaiend ei tohi olla {extension}",
"file_input": {
"duplicate_files": "Järgmised failid on juba üles laaditud: {duplicateNames}. Duplikaatfailid ei ole lubatud.",
"file_size_exceeded": "Järgmised failid ületavad maksimaalse suuruse {maxSizeInMB} MB ja eemaldati: {fileNames}",
"file_size_exceeded_alert": "Fail peab olema väiksem kui {maxSizeInMB} MB",
"no_valid_file_types_selected": "Ühtegi kehtivat failitüüpi pole valitud. Palun vali kehtiv failitüüp.",
"only_one_file_can_be_uploaded_at_a_time": "Korraga saab üles laadida ainult ühe faili.",
"placeholder_text": "Klõpsa või lohista failide üleslaadimiseks",
"upload_failed": "Üleslaadimine ebaõnnestus! Palun proovi uuesti.",
"uploading": "Üleslaadimine...",
"you_can_only_upload_a_maximum_of_files": "Saad üles laadida maksimaalselt {FILE_LIMIT} faili."
},
"invalid_device_error": {
"message": "Palun keela küsitluse seadetes rämpsposti kaitse, et jätkata selle seadmega.",
"title": "See seade ei toeta rämpsposti kaitset."
},
"invalid_format": "Palun sisesta kehtiv vorming",
"is_between": "Palun vali kuupäev vahemikus {startDate} kuni {endDate}",
"is_earlier_than": "Palun vali kuupäev enne {date}",
"is_greater_than": "Palun sisesta väärtus, mis on suurem kui {min}",
"is_later_than": "Palun vali kuupäev pärast {date}",
"is_less_than": "Palun sisesta väärtus, mis on väiksem kui {max}",
"is_not_between": "Palun vali kuupäev, mis ei jää vahemikku {startDate} kuni {endDate}",
"max_length": "Palun sisesta mitte rohkem kui {max} tähemärki",
"max_selections": "Palun vali mitte rohkem kui {max} varianti",
"max_value": "Palun sisesta väärtus, mis ei ole suurem kui {max}",
"min_length": "Palun sisesta vähemalt {min} tähemärki",
"min_selections": "Palun vali vähemalt {min} varianti",
"min_value": "Palun sisesta väärtus vähemalt {min}",
"minimum_options_ranked": "Palun järjesta vähemalt {min} varianti",
"minimum_rows_answered": "Palun vasta vähemalt {min} reale",
"please_enter_a_valid_email_address": "Palun sisesta kehtiv e-posti aadress",
"please_enter_a_valid_phone_number": "Palun sisesta kehtiv telefoninumber",
"please_enter_a_valid_url": "Palun sisesta kehtiv URL",
"please_fill_out_this_field": "Palun täida see väli",
"recaptcha_error": {
"message": "Sinu vastust ei saanud esitada, kuna see märgiti automatiseeritud tegevuseks. Kui sa hingad, palun proovi uuesti.",
"title": "Me ei suutnud kinnitada, et sa oled inimene."
},
"value_must_contain": "Väärtus peab sisaldama {value}",
"value_must_equal": "Väärtus peab võrduma {value}",
"value_must_not_contain": "Väärtus ei tohi sisaldada {value}",
"value_must_not_equal": "Väärtus ei tohi võrduda {value}"
}
}

View File

@@ -6,6 +6,7 @@ import daTranslations from "../../locales/da.json";
import deTranslations from "../../locales/de.json";
import enTranslations from "../../locales/en.json";
import esTranslations from "../../locales/es.json";
import etTranslations from "../../locales/et.json";
import frTranslations from "../../locales/fr.json";
import hiTranslations from "../../locales/hi.json";
import huTranslations from "../../locales/hu.json";
@@ -30,6 +31,7 @@ i18n
"de",
"en",
"es",
"et",
"fr",
"hi",
"hu",
@@ -50,6 +52,7 @@ i18n
de: { translation: deTranslations },
en: { translation: enTranslations },
es: { translation: esTranslations },
et: { translation: etTranslations },
fr: { translation: frTranslations },
hi: { translation: hiTranslations },
hu: { translation: huTranslations },

View File

@@ -135,7 +135,6 @@ export const ZActionClass = z.object({
key: z.string().trim().min(1).nullable(),
noCodeConfig: ZActionClassNoCodeConfig.nullable(),
environmentId: z.string(),
projectId: z.string().nullable(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});

View File

@@ -19,7 +19,6 @@ export const ZContactAttributeKey = z.object({
type: ZContactAttributeKeyType,
dataType: ZContactAttributeDataType.prefault("string"),
environmentId: z.string(),
projectId: z.string().nullable(),
});
export type TContactAttributeKey = z.infer<typeof ZContactAttributeKey>;

View File

@@ -19,7 +19,6 @@ export type TIntegrationConfig = z.infer<typeof ZIntegrationConfig>;
export const ZIntegrationBase = z.object({
id: z.string(),
environmentId: z.string(),
projectId: z.string().nullable(),
});
export const ZIntegration = ZIntegrationBase.extend({

View File

@@ -3,7 +3,6 @@ import { z } from "zod";
export const ZIntegrationBase = z.object({
id: z.string(),
environmentId: z.string(),
projectId: z.string().nullable(),
});
export const ZIntegrationBaseSurveyData = z.object({

Some files were not shown because too many files have changed in this diff Show More