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
62 changed files with 2642 additions and 225 deletions

View File

@@ -185,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

@@ -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

@@ -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";

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(),
@@ -141,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,

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

@@ -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

@@ -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

@@ -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,6 +23,7 @@ export const WebhookTable = ({
surveys,
children: [TableHeading, webhookRows],
isReadOnly,
allowInternalUrls,
}: WebhookTableProps) => {
const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false);
const { t } = useTranslation();
@@ -71,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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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) | |

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

@@ -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

@@ -853,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.
///
@@ -878,6 +904,7 @@ model User {
identityProviderAccountId String?
memberships Membership[]
accounts Account[]
sessions Session[]
groupId String?
invitesCreated Invite[] @relation("inviteCreatedBy")
invitesAccepted Invite[] @relation("inviteAcceptedBy")

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

@@ -1,11 +1,13 @@
import NextAuth from "next-auth";
import { type TUser } from "./user";
import NextAuth, { type DefaultSession } from "next-auth";
declare module "next-auth" {
/**
* Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
*/
interface Session {
user: { id: string };
user: DefaultSession["user"] & {
id: string;
isActive?: boolean;
};
}
}

View File

@@ -1,8 +1,30 @@
diff --git a/core/lib/assert.js b/core/lib/assert.js
--- a/core/lib/assert.js
+++ b/core/lib/assert.js
@@ -52,12 +52,6 @@
if (provider.type === "credentials") hasCredentials = true;else if (provider.type === "email") hasEmail = true;else if (provider.id === "twitter" && provider.version === "2.0") hasTwitterOAuth2 = true;
}
if (hasCredentials) {
- var _options$session;
- const dbStrategy = ((_options$session = options.session) === null || _options$session === void 0 ? void 0 : _options$session.strategy) === "database";
- const onlyCredentials = !options.providers.some(p => p.type !== "credentials");
- if (dbStrategy && onlyCredentials) {
- return new _errors.UnsupportedStrategy("Signin in with credentials only supported if JWT strategy is enabled");
- }
const credentialsNoAuthorize = options.providers.some(p => p.type === "credentials" && !p.authorize);
if (credentialsNoAuthorize) {
return new _errors.MissingAuthorize("Must define an authorize() handler to use credentials authentication provider");
@@ -80,4 +74,4 @@
warned = true;
}
return warnings;
-}
\ No newline at end of file
+}
diff --git a/core/lib/oauth/client.js b/core/lib/oauth/client.js
index 52c51eb6ff422dc0899ccec31baf3fa39e42eeae..472772cfefc2c2947536d6a22b022c2f9c27c61f 100644
--- a/core/lib/oauth/client.js
+++ b/core/lib/oauth/client.js
@@ -5,9 +5,73 @@ Object.defineProperty(exports, "__esModule", {
@@ -5,9 +5,73 @@
});
exports.openidClient = openidClient;
var _openidClient = require("openid-client");
@@ -77,3 +99,199 @@ index 52c51eb6ff422dc0899ccec31baf3fa39e42eeae..472772cfefc2c2947536d6a22b022c2f
let issuer;
if (provider.wellKnown) {
issuer = await _openidClient.Issuer.discover(provider.wellKnown);
diff --git a/core/routes/callback.js b/core/routes/callback.js
--- a/core/routes/callback.js
+++ b/core/routes/callback.js
@@ -377,29 +377,48 @@
cookies
};
}
- const defaultToken = {
- name: user.name,
- email: user.email,
- picture: user.image,
- sub: (_user$id3 = user.id) === null || _user$id3 === void 0 ? void 0 : _user$id3.toString()
- };
- const token = await callbacks.jwt({
- token: defaultToken,
- user,
- account,
- isNewUser: false,
- trigger: "signIn"
- });
- const newToken = await jwt.encode({
- ...jwt,
- token
- });
- const cookieExpires = new Date();
- cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000);
- const sessionCookies = sessionStore.chunk(newToken, {
- expires: cookieExpires
- });
- cookies.push(...sessionCookies);
+ if (useJwtSession) {
+ const defaultToken = {
+ name: user.name,
+ email: user.email,
+ picture: user.image,
+ sub: (_user$id3 = user.id) === null || _user$id3 === void 0 ? void 0 : _user$id3.toString()
+ };
+ const token = await callbacks.jwt({
+ token: defaultToken,
+ user,
+ account,
+ isNewUser: false,
+ trigger: "signIn"
+ });
+ const newToken = await jwt.encode({
+ ...jwt,
+ token
+ });
+ const cookieExpires = new Date();
+ cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000);
+ const sessionCookies = sessionStore.chunk(newToken, {
+ expires: cookieExpires
+ });
+ cookies.push(...sessionCookies);
+ } else {
+ if (!adapter) {
+ throw new Error("Missing adapter");
+ }
+ const session = await adapter.createSession({
+ sessionToken: await options.session.generateSessionToken(),
+ userId: user.id,
+ expires: (0, _utils.fromDate)(options.session.maxAge)
+ });
+ cookies.push({
+ name: options.cookies.sessionToken.name,
+ value: session.sessionToken,
+ options: {
+ ...options.cookies.sessionToken.options,
+ expires: session.expires
+ }
+ });
+ }
await ((_events$signIn3 = events.signIn) === null || _events$signIn3 === void 0 ? void 0 : _events$signIn3.call(events, {
user,
account
@@ -414,4 +433,4 @@
body: `Error: Callback for provider type ${provider.type} not supported`,
cookies
};
-}
\ No newline at end of file
+}
diff --git a/src/core/lib/assert.ts b/src/core/lib/assert.ts
--- a/src/core/lib/assert.ts
+++ b/src/core/lib/assert.ts
@@ -101,16 +101,6 @@
}
if (hasCredentials) {
- const dbStrategy = options.session?.strategy === "database"
- const onlyCredentials = !options.providers.some(
- (p) => p.type !== "credentials"
- )
- if (dbStrategy && onlyCredentials) {
- return new UnsupportedStrategy(
- "Signin in with credentials only supported if JWT strategy is enabled"
- )
- }
-
const credentialsNoAuthorize = options.providers.some(
(p) => p.type === "credentials" && !p.authorize
)
diff --git a/src/core/routes/callback.ts b/src/core/routes/callback.ts
--- a/src/core/routes/callback.ts
+++ b/src/core/routes/callback.ts
@@ -1,6 +1,6 @@
import oAuthCallback from "../lib/oauth/callback"
import callbackHandler from "../lib/callback-handler"
-import { hashToken } from "../lib/utils"
+import { fromDate, hashToken } from "../lib/utils"
import getAdapterUserFromEmail from "../lib/email/getUserFromEmail"
import type { InternalOptions } from "../types"
@@ -385,37 +385,58 @@
)}`,
cookies,
}
- }
-
- const defaultToken = {
- name: user.name,
- email: user.email,
- picture: user.image,
- sub: user.id?.toString(),
}
- const token = await callbacks.jwt({
- token: defaultToken,
- user,
- // @ts-expect-error
- account,
- isNewUser: false,
- trigger: "signIn",
- })
+ if (useJwtSession) {
+ const defaultToken = {
+ name: user.name,
+ email: user.email,
+ picture: user.image,
+ sub: user.id?.toString(),
+ }
- // Encode token
- const newToken = await jwt.encode({ ...jwt, token })
+ const token = await callbacks.jwt({
+ token: defaultToken,
+ user,
+ // @ts-expect-error
+ account,
+ isNewUser: false,
+ trigger: "signIn",
+ })
- // Set cookie expiry date
- const cookieExpires = new Date()
- cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
+ // Encode token
+ const newToken = await jwt.encode({ ...jwt, token })
- const sessionCookies = sessionStore.chunk(newToken, {
- expires: cookieExpires,
- })
+ // Set cookie expiry date
+ const cookieExpires = new Date()
+ cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
- cookies.push(...sessionCookies)
+ const sessionCookies = sessionStore.chunk(newToken, {
+ expires: cookieExpires,
+ })
+ cookies.push(...sessionCookies)
+ } else {
+ if (!adapter) {
+ throw new Error("Missing adapter")
+ }
+
+ const session = await adapter.createSession({
+ sessionToken: await options.session.generateSessionToken(),
+ userId: user.id,
+ expires: fromDate(options.session.maxAge),
+ })
+
+ cookies.push({
+ name: options.cookies.sessionToken.name,
+ value: (session as AdapterSession).sessionToken,
+ options: {
+ ...options.cookies.sessionToken.options,
+ expires: (session as AdapterSession).expires,
+ },
+ })
+ }
+
// @ts-expect-error
await events.signIn?.({ user, account })

54
pnpm-lock.yaml generated
View File

@@ -25,7 +25,7 @@ overrides:
patchedDependencies:
next-auth@4.24.13:
hash: 7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b
hash: 6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798
path: patches/next-auth@4.24.13.patch
importers:
@@ -183,6 +183,9 @@ importers:
'@lexical/table':
specifier: 0.41.0
version: 0.41.0
'@next-auth/prisma-adapter':
specifier: 1.0.7
version: 1.0.7(@prisma/client@6.19.2(prisma@7.4.2(@types/react@19.2.14)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(next-auth@4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
'@opentelemetry/auto-instrumentations-node':
specifier: 0.71.0
version: 0.71.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))
@@ -347,7 +350,7 @@ importers:
version: 16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-auth:
specifier: 4.24.13
version: 4.24.13(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-safe-action:
specifier: 8.1.8
version: 8.1.8(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -2348,6 +2351,12 @@ packages:
'@neoconfetti/react@1.0.0':
resolution: {integrity: sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==}
'@next-auth/prisma-adapter@1.0.7':
resolution: {integrity: sha512-Cdko4KfcmKjsyHFrWwZ//lfLUbcLqlyFqjd/nYE2m3aZ7tjMNUjpks47iw7NTCnXf+5UWz5Ypyt1dSs1EP5QJw==}
peerDependencies:
'@prisma/client': '>=2.26.0 || >=3'
next-auth: ^4
'@next/env@16.1.7':
resolution: {integrity: sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==}
@@ -13200,6 +13209,11 @@ snapshots:
'@neoconfetti/react@1.0.0': {}
'@next-auth/prisma-adapter@1.0.7(@prisma/client@6.19.2(prisma@7.4.2(@types/react@19.2.14)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(next-auth@4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
dependencies:
'@prisma/client': 6.19.2(prisma@7.4.2(@types/react@19.2.14)(magicast@0.3.5)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
next-auth: 4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@next/env@16.1.7': {}
'@next/eslint-plugin-next@15.5.12':
@@ -17136,10 +17150,10 @@ snapshots:
'@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3)
eslint-config-prettier: 9.1.2(eslint@8.57.1)
eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.32.0)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
eslint-import-resolver-alias: 1.1.2(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jest: 27.9.0(@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-playwright: 1.8.3(eslint-plugin-jest@27.9.0(@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)
@@ -18564,8 +18578,8 @@ snapshots:
'@typescript-eslint/parser': 8.57.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
@@ -18590,9 +18604,9 @@ snapshots:
eslint-plugin-turbo: 2.8.16(eslint@8.57.1)(turbo@2.8.16)
turbo: 2.8.16
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0):
eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)):
dependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-import-resolver-node@0.3.9:
dependencies:
@@ -18602,7 +18616,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -18613,29 +18627,29 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
eslint-module-utils@2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.57.0(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
@@ -18645,7 +18659,7 @@ snapshots:
eslint: 8.57.1
ignore: 5.3.2
eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -18656,7 +18670,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -18674,7 +18688,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -18685,7 +18699,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -20417,7 +20431,7 @@ snapshots:
neo-async@2.6.2: {}
next-auth@4.24.13(patch_hash=7ac5717a8d7d2049442182b5d83ab492d33fe774ff51ff5ea3884628b77df87b)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
next-auth@4.24.13(patch_hash=6b21102fce2caaca35f5e4e93ea07a0b4ff486cbf3d3b09a4173ad45977d5798)(next@16.1.7(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@8.0.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.4
'@panva/hkdf': 1.2.1

View File

@@ -141,6 +141,7 @@
"BREVO_API_KEY",
"BREVO_LIST_ID",
"CRON_SECRET",
"DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS",
"DATABASE_URL",
"DEBUG",
"E2E_TESTING",