mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-01 21:22:38 -05:00
Compare commits
16 Commits
chore/add-
...
feat/impor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0758f7526 | ||
|
|
4cfb8c6d7b | ||
|
|
e74a51a5ff | ||
|
|
29cc6a10fe | ||
|
|
01f765e969 | ||
|
|
9366960f18 | ||
|
|
697dc9cc99 | ||
|
|
83bc272ed2 | ||
|
|
902b8c92e2 | ||
|
|
17ba0f21af | ||
|
|
a384743751 | ||
|
|
dfa1c3e375 | ||
|
|
77c9302183 | ||
|
|
88da043c00 | ||
|
|
1cc3ceec55 | ||
|
|
50d15f6e07 |
@@ -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
|
||||
|
||||
139
apps/web/app/api/auth/[...nextauth]/route.test.ts
Normal file
139
apps/web/app/api/auth/[...nextauth]/route.test.ts
Normal 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",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
97
apps/web/lib/account/service.test.ts
Normal file
97
apps/web/lib/account/service.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
115
apps/web/modules/auth/lib/proxy-session.test.ts
Normal file
115
apps/web/modules/auth/lib/proxy-session.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
54
apps/web/modules/auth/lib/proxy-session.ts
Normal file
54
apps/web/modules/auth/lib/proxy-session.ts
Normal 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;
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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://" };
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
320
apps/web/modules/survey/list/components/import-survey-modal.tsx
Normal file
320
apps/web/modules/survey/list/components/import-survey-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
10
apps/web/modules/survey/list/lib/download-survey.ts
Normal file
10
apps/web/modules/survey/list/lib/download-survey.ts
Normal 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);
|
||||
};
|
||||
145
apps/web/modules/survey/list/lib/export-survey.ts
Normal file
145
apps/web/modules/survey/list/lib/export-survey.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
119
apps/web/modules/survey/list/lib/import-helpers.ts
Normal file
119
apps/web/modules/survey/list/lib/import-helpers.ts
Normal 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;
|
||||
};
|
||||
11
apps/web/modules/survey/list/lib/import/index.ts
Normal file
11
apps/web/modules/survey/list/lib/import/index.ts
Normal 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";
|
||||
41
apps/web/modules/survey/list/lib/import/map-languages.ts
Normal file
41
apps/web/modules/survey/list/lib/import/map-languages.ts
Normal 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 };
|
||||
};
|
||||
55
apps/web/modules/survey/list/lib/import/map-triggers.ts
Normal file
55
apps/web/modules/survey/list/lib/import/map-triggers.ts
Normal 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 };
|
||||
};
|
||||
60
apps/web/modules/survey/list/lib/import/normalize-survey.ts
Normal file
60
apps/web/modules/survey/list/lib/import/normalize-survey.ts
Normal 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);
|
||||
};
|
||||
98
apps/web/modules/survey/list/lib/import/parse-payload.ts
Normal file
98
apps/web/modules/survey/list/lib/import/parse-payload.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
34
apps/web/modules/survey/list/lib/import/permissions.ts
Normal file
34
apps/web/modules/survey/list/lib/import/permissions.ts
Normal 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 };
|
||||
};
|
||||
34
apps/web/modules/survey/list/lib/import/persist-survey.ts
Normal file
34
apps/web/modules/survey/list/lib/import/persist-survey.ts
Normal 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 };
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
85
apps/web/proxy.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) | |
|
||||
|
||||
@@ -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). 😃
|
||||
|
||||
---
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
85
packages/surveys/locales/et.json
Normal file
85
packages/surveys/locales/et.json
Normal 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}"
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
8
packages/types/next-auth.d.ts
vendored
8
packages/types/next-auth.d.ts
vendored
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
54
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"BREVO_API_KEY",
|
||||
"BREVO_LIST_ID",
|
||||
"CRON_SECRET",
|
||||
"DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS",
|
||||
"DATABASE_URL",
|
||||
"DEBUG",
|
||||
"E2E_TESTING",
|
||||
|
||||
Reference in New Issue
Block a user