mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-31 22:03:31 -05:00
Compare commits
16 Commits
chore/depr
...
feat/impor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0758f7526 | ||
|
|
4cfb8c6d7b | ||
|
|
e74a51a5ff | ||
|
|
29cc6a10fe | ||
|
|
01f765e969 | ||
|
|
9366960f18 | ||
|
|
697dc9cc99 | ||
|
|
83bc272ed2 | ||
|
|
902b8c92e2 | ||
|
|
17ba0f21af | ||
|
|
a384743751 | ||
|
|
dfa1c3e375 | ||
|
|
77c9302183 | ||
|
|
88da043c00 | ||
|
|
1cc3ceec55 | ||
|
|
50d15f6e07 |
14
.env.example
14
.env.example
@@ -38,15 +38,6 @@ LOG_LEVEL=info
|
||||
|
||||
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
|
||||
|
||||
#################
|
||||
# HUB (DEV) #
|
||||
#################
|
||||
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
|
||||
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
|
||||
HUB_API_KEY=dev-api-key
|
||||
HUB_API_URL=http://localhost:8080
|
||||
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
|
||||
################
|
||||
# MAIL SETUP #
|
||||
################
|
||||
@@ -194,6 +185,11 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Ignore Rate Limiting across the Formbricks app
|
||||
# RATE_LIMITING_DISABLED=1
|
||||
|
||||
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
|
||||
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
|
||||
# that need to send webhooks to internal services.
|
||||
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
|
||||
|
||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
|
||||
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);
|
||||
|
||||
@@ -45,7 +45,6 @@ export const responseSelection = {
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,7 +19,6 @@ const selectActionClass = {
|
||||
key: true,
|
||||
noCodeConfig: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
} satisfies Prisma.ActionClassSelect;
|
||||
|
||||
export const getActionClasses = reactCache(async (environmentIds: string[]): Promise<TActionClass[]> => {
|
||||
|
||||
@@ -50,7 +50,6 @@ export const responseSelection = {
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4823,7 +4823,6 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
name: t("templates.preview_survey_name"),
|
||||
type: "link" as const,
|
||||
environmentId: "cltwumfcz0009echxg02fh7oa",
|
||||
projectId: null,
|
||||
createdBy: "cltwumfbz0000echxysz6ptvq",
|
||||
status: "inProgress" as const,
|
||||
welcomeCard: {
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ const selectActionClass = {
|
||||
key: true,
|
||||
noCodeConfig: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
} satisfies Prisma.ActionClassSelect;
|
||||
|
||||
export const getActionClasses = reactCache(
|
||||
|
||||
@@ -26,6 +26,7 @@ export const TERMS_URL = env.TERMS_URL;
|
||||
export const IMPRINT_URL = env.IMPRINT_URL;
|
||||
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
|
||||
|
||||
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
|
||||
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
|
||||
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
|
||||
|
||||
@@ -40,8 +41,6 @@ export const GITHUB_ID = env.GITHUB_ID;
|
||||
export const GITHUB_SECRET = env.GITHUB_SECRET;
|
||||
export const GOOGLE_CLIENT_ID = env.GOOGLE_CLIENT_ID;
|
||||
export const GOOGLE_CLIENT_SECRET = env.GOOGLE_CLIENT_SECRET;
|
||||
export const HUB_API_URL = env.HUB_API_URL;
|
||||
export const HUB_API_KEY = env.HUB_API_KEY;
|
||||
|
||||
export const AZUREAD_CLIENT_ID = env.AZUREAD_CLIENT_ID;
|
||||
export const AZUREAD_CLIENT_SECRET = env.AZUREAD_CLIENT_SECRET;
|
||||
|
||||
@@ -15,6 +15,7 @@ export const env = createEnv({
|
||||
BREVO_API_KEY: z.string().optional(),
|
||||
BREVO_LIST_ID: z.string().optional(),
|
||||
DATABASE_URL: z.url(),
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
|
||||
DEBUG: z.enum(["1", "0"]).optional(),
|
||||
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
|
||||
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
|
||||
@@ -33,8 +34,6 @@ export const env = createEnv({
|
||||
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
|
||||
HTTP_PROXY: z.url().optional(),
|
||||
HTTPS_PROXY: z.url().optional(),
|
||||
HUB_API_URL: z.url(),
|
||||
HUB_API_KEY: z.string().optional(),
|
||||
IMPRINT_URL: z
|
||||
.url()
|
||||
.optional()
|
||||
@@ -143,6 +142,7 @@ export const env = createEnv({
|
||||
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
|
||||
CRON_SECRET: process.env.CRON_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
|
||||
DEBUG: process.env.DEBUG,
|
||||
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
|
||||
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
|
||||
@@ -161,8 +161,6 @@ export const env = createEnv({
|
||||
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
|
||||
HTTP_PROXY: process.env.HTTP_PROXY,
|
||||
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
||||
HUB_API_URL: process.env.HUB_API_URL,
|
||||
HUB_API_KEY: process.env.HUB_API_KEY,
|
||||
IMPRINT_URL: process.env.IMPRINT_URL,
|
||||
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
|
||||
INVITE_DISABLED: process.env.INVITE_DISABLED,
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ export const responseSelection = {
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,7 +19,6 @@ const selectContact = {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
attributes: {
|
||||
select: {
|
||||
value: true,
|
||||
@@ -42,7 +41,6 @@ const commonMockProperties = {
|
||||
createdAt: currentDate,
|
||||
updatedAt: currentDate,
|
||||
environmentId: mockId,
|
||||
projectId: null,
|
||||
};
|
||||
|
||||
type SurveyMock = Prisma.SurveyGetPayload<{
|
||||
|
||||
@@ -30,7 +30,6 @@ export const selectSurvey = {
|
||||
name: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
createdBy: true,
|
||||
status: true,
|
||||
welcomeCard: true,
|
||||
@@ -85,7 +84,6 @@ export const selectSurvey = {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -58,7 +58,6 @@ export const getResponseForPipeline = async (
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -184,7 +184,6 @@ describe("Response Lib", () => {
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,7 +17,6 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
secret: true,
|
||||
}).meta({
|
||||
id: "webhookUpdate",
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -98,7 +98,6 @@ const selectContact = {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
attributes: {
|
||||
select: {
|
||||
value: true,
|
||||
|
||||
@@ -45,7 +45,6 @@ export function CreateSegmentModal({
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
environmentId,
|
||||
projectId: null,
|
||||
id: "",
|
||||
surveys: [],
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -55,7 +55,6 @@ export const selectSegment = {
|
||||
title: true,
|
||||
description: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
filters: true,
|
||||
isPrivate: true,
|
||||
surveys: {
|
||||
|
||||
@@ -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,12 +23,12 @@ export const WebhookTable = ({
|
||||
surveys,
|
||||
children: [TableHeading, webhookRows],
|
||||
isReadOnly,
|
||||
allowInternalUrls,
|
||||
}: WebhookTableProps) => {
|
||||
const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const [activeWebhook, setActiveWebhook] = useState<Webhook>({
|
||||
environmentId: environment.id,
|
||||
projectId: null,
|
||||
id: "",
|
||||
name: "",
|
||||
url: "",
|
||||
@@ -72,6 +73,7 @@ export const WebhookTable = ({
|
||||
webhook={activeWebhook}
|
||||
surveys={surveys}
|
||||
isReadOnly={isReadOnly}
|
||||
allowInternalUrls={allowInternalUrls}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
|
||||
isPrivate: true,
|
||||
title: localSurvey.id,
|
||||
environmentId: environment.id,
|
||||
projectId: null,
|
||||
surveys: [localSurvey.id],
|
||||
filters: [],
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ export const selectSurvey = {
|
||||
name: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
createdBy: true,
|
||||
status: true,
|
||||
welcomeCard: true,
|
||||
@@ -70,7 +69,6 @@ export const selectSurvey = {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
@@ -86,7 +84,6 @@ export const selectSurvey = {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
title: true,
|
||||
description: true,
|
||||
isPrivate: true,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -15,7 +15,6 @@ export const surveySelect = {
|
||||
status: true,
|
||||
singleUse: true,
|
||||
environmentId: true,
|
||||
projectId: true,
|
||||
_count: {
|
||||
select: { responses: true },
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -9,7 +9,6 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
|
||||
name: "Minimal Survey",
|
||||
type: "app",
|
||||
environmentId: "someEnvId1",
|
||||
projectId: null,
|
||||
createdBy: null,
|
||||
status: "draft",
|
||||
displayOption: "displayOnce",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# formbricks
|
||||
|
||||
  
|
||||
 
|
||||
|
||||
A Helm chart for Formbricks with PostgreSQL, Redis
|
||||
|
||||
@@ -8,178 +8,150 @@ A Helm chart for Formbricks with PostgreSQL, Redis
|
||||
|
||||
## Maintainers
|
||||
|
||||
| Name | Email | Url |
|
||||
| ---- | ------ | --- |
|
||||
| Formbricks | <info@formbricks.com> | |
|
||||
| Name | Email | Url |
|
||||
| ---------- | --------------------- | --- |
|
||||
| Formbricks | <info@formbricks.com> | |
|
||||
|
||||
## Requirements
|
||||
|
||||
| Repository | Name | Version |
|
||||
|------------|------|---------|
|
||||
| Repository | Name | Version |
|
||||
| ---------------------------------------- | ---------- | ------- |
|
||||
| oci://registry-1.docker.io/bitnamicharts | postgresql | 16.4.16 |
|
||||
| oci://registry-1.docker.io/bitnamicharts | redis | 20.11.2 |
|
||||
| oci://registry-1.docker.io/bitnamicharts | redis | 20.11.2 |
|
||||
|
||||
## Values
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| autoscaling.additionalLabels | object | `{}` | |
|
||||
| autoscaling.annotations | object | `{}` | |
|
||||
| autoscaling.behavior.scaleDown.policies[0].periodSeconds | int | `120` | |
|
||||
| autoscaling.behavior.scaleDown.policies[0].type | string | `"Pods"` | |
|
||||
| autoscaling.behavior.scaleDown.policies[0].value | int | `1` | |
|
||||
| autoscaling.behavior.scaleDown.stabilizationWindowSeconds | int | `300` | |
|
||||
| autoscaling.behavior.scaleUp.policies[0].periodSeconds | int | `60` | |
|
||||
| autoscaling.behavior.scaleUp.policies[0].type | string | `"Pods"` | |
|
||||
| autoscaling.behavior.scaleUp.policies[0].value | int | `2` | |
|
||||
| autoscaling.behavior.scaleUp.stabilizationWindowSeconds | int | `60` | |
|
||||
| autoscaling.enabled | bool | `true` | |
|
||||
| autoscaling.maxReplicas | int | `10` | |
|
||||
| autoscaling.metrics[0].resource.name | string | `"cpu"` | |
|
||||
| autoscaling.metrics[0].resource.target.averageUtilization | int | `60` | |
|
||||
| autoscaling.metrics[0].resource.target.type | string | `"Utilization"` | |
|
||||
| autoscaling.metrics[0].type | string | `"Resource"` | |
|
||||
| autoscaling.metrics[1].resource.name | string | `"memory"` | |
|
||||
| autoscaling.metrics[1].resource.target.averageUtilization | int | `60` | |
|
||||
| autoscaling.metrics[1].resource.target.type | string | `"Utilization"` | |
|
||||
| autoscaling.metrics[1].type | string | `"Resource"` | |
|
||||
| autoscaling.minReplicas | int | `1` | |
|
||||
| componentOverride | string | `""` | |
|
||||
| deployment.additionalLabels | object | `{}` | |
|
||||
| deployment.additionalPodAnnotations | object | `{}` | |
|
||||
| deployment.additionalPodLabels | object | `{}` | |
|
||||
| deployment.affinity | object | `{}` | |
|
||||
| deployment.annotations | object | `{}` | |
|
||||
| deployment.args | list | `[]` | |
|
||||
| deployment.command | list | `[]` | |
|
||||
| deployment.containerSecurityContext.readOnlyRootFilesystem | bool | `true` | |
|
||||
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
|
||||
| deployment.env | object | `{}` | |
|
||||
| deployment.envFrom | string | `nil` | |
|
||||
| deployment.image.digest | string | `""` | |
|
||||
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
|
||||
| deployment.image.tag | string | `""` | |
|
||||
| deployment.imagePullSecrets | string | `""` | |
|
||||
| deployment.nodeSelector | object | `{}` | |
|
||||
| deployment.ports.http.containerPort | int | `3000` | |
|
||||
| deployment.ports.http.exposed | bool | `true` | |
|
||||
| deployment.ports.http.protocol | string | `"TCP"` | |
|
||||
| deployment.ports.metrics.containerPort | int | `9464` | |
|
||||
| deployment.ports.metrics.exposed | bool | `true` | |
|
||||
| deployment.ports.metrics.protocol | string | `"TCP"` | |
|
||||
| deployment.probes.livenessProbe.failureThreshold | int | `5` | |
|
||||
| deployment.probes.livenessProbe.httpGet.path | string | `"/health"` | |
|
||||
| deployment.probes.livenessProbe.httpGet.port | int | `3000` | |
|
||||
| deployment.probes.livenessProbe.initialDelaySeconds | int | `10` | |
|
||||
| deployment.probes.livenessProbe.periodSeconds | int | `10` | |
|
||||
| deployment.probes.livenessProbe.successThreshold | int | `1` | |
|
||||
| deployment.probes.livenessProbe.timeoutSeconds | int | `5` | |
|
||||
| deployment.probes.readinessProbe.failureThreshold | int | `5` | |
|
||||
| deployment.probes.readinessProbe.httpGet.path | string | `"/health"` | |
|
||||
| deployment.probes.readinessProbe.httpGet.port | int | `3000` | |
|
||||
| deployment.probes.readinessProbe.initialDelaySeconds | int | `10` | |
|
||||
| deployment.probes.readinessProbe.periodSeconds | int | `10` | |
|
||||
| deployment.probes.readinessProbe.successThreshold | int | `1` | |
|
||||
| deployment.probes.readinessProbe.timeoutSeconds | int | `5` | |
|
||||
| deployment.probes.startupProbe.failureThreshold | int | `30` | |
|
||||
| deployment.probes.startupProbe.periodSeconds | int | `10` | |
|
||||
| deployment.probes.startupProbe.tcpSocket.port | int | `3000` | |
|
||||
| deployment.reloadOnChange | bool | `false` | |
|
||||
| deployment.replicas | int | `1` | |
|
||||
| deployment.resources.limits.memory | string | `"2Gi"` | |
|
||||
| deployment.resources.requests.cpu | string | `"1"` | |
|
||||
| deployment.resources.requests.memory | string | `"1Gi"` | |
|
||||
| deployment.revisionHistoryLimit | int | `2` | |
|
||||
| deployment.securityContext | object | `{}` | |
|
||||
| deployment.strategy.type | string | `"RollingUpdate"` | |
|
||||
| deployment.tolerations | list | `[]` | |
|
||||
| deployment.topologySpreadConstraints | list | `[]` | |
|
||||
| enterprise.enabled | bool | `false` | |
|
||||
| enterprise.licenseKey | string | `""` | |
|
||||
| externalSecret.enabled | bool | `false` | |
|
||||
| externalSecret.files | object | `{}` | |
|
||||
| externalSecret.refreshInterval | string | `"1h"` | |
|
||||
| externalSecret.secretStore.kind | string | `"ClusterSecretStore"` | |
|
||||
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
|
||||
| formbricks.publicUrl | string | `""` | |
|
||||
| formbricks.webappUrl | string | `""` | |
|
||||
| hub.enabled | bool | `true` | |
|
||||
| hub.env | object | `{}` | |
|
||||
| hub.existingSecret | string | `""` | |
|
||||
| hub.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| hub.image.repository | string | `"ghcr.io/formbricks/hub"` | |
|
||||
| hub.image.tag | string | `"1.0.0"` | |
|
||||
| hub.migration.activeDeadlineSeconds | int | `900` | |
|
||||
| hub.migration.backoffLimit | int | `3` | |
|
||||
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
|
||||
| hub.replicas | int | `1` | |
|
||||
| hub.resources.limits.memory | string | `"512Mi"` | |
|
||||
| hub.resources.requests.cpu | string | `"100m"` | |
|
||||
| hub.resources.requests.memory | string | `"256Mi"` | |
|
||||
| ingress.annotations | object | `{}` | |
|
||||
| ingress.enabled | bool | `false` | |
|
||||
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
|
||||
| ingress.hosts[0].paths[0].path | string | `"/"` | |
|
||||
| ingress.hosts[0].paths[0].pathType | string | `"Prefix"` | |
|
||||
| ingress.hosts[0].paths[0].serviceName | string | `"formbricks"` | |
|
||||
| ingress.ingressClassName | string | `"alb"` | |
|
||||
| migration.annotations | object | `{}` | |
|
||||
| migration.backoffLimit | int | `3` | |
|
||||
| migration.enabled | bool | `true` | |
|
||||
| migration.resources.limits.memory | string | `"512Mi"` | |
|
||||
| migration.resources.requests.cpu | string | `"100m"` | |
|
||||
| migration.resources.requests.memory | string | `"256Mi"` | |
|
||||
| migration.ttlSecondsAfterFinished | int | `300` | |
|
||||
| nameOverride | string | `""` | |
|
||||
| partOfOverride | string | `""` | |
|
||||
| pdb.additionalLabels | object | `{}` | |
|
||||
| pdb.annotations | object | `{}` | |
|
||||
| pdb.enabled | bool | `true` | |
|
||||
| pdb.minAvailable | int | `1` | |
|
||||
| postgresql.auth.database | string | `"formbricks"` | |
|
||||
| postgresql.auth.existingSecret | string | `"formbricks-app-secrets"` | |
|
||||
| postgresql.auth.secretKeys.adminPasswordKey | string | `"POSTGRES_ADMIN_PASSWORD"` | |
|
||||
| postgresql.auth.secretKeys.userPasswordKey | string | `"POSTGRES_USER_PASSWORD"` | |
|
||||
| postgresql.auth.username | string | `"formbricks"` | |
|
||||
| postgresql.enabled | bool | `true` | |
|
||||
| postgresql.externalDatabaseUrl | string | `""` | |
|
||||
| postgresql.fullnameOverride | string | `"formbricks-postgresql"` | |
|
||||
| postgresql.global.security.allowInsecureImages | bool | `true` | |
|
||||
| postgresql.image.repository | string | `"pgvector/pgvector"` | |
|
||||
| postgresql.image.tag | string | `"pg17"` | |
|
||||
| postgresql.primary.containerSecurityContext.enabled | bool | `true` | |
|
||||
| postgresql.primary.containerSecurityContext.readOnlyRootFilesystem | bool | `false` | |
|
||||
| postgresql.primary.containerSecurityContext.runAsUser | int | `1001` | |
|
||||
| postgresql.primary.networkPolicy.enabled | bool | `false` | |
|
||||
| postgresql.primary.persistence.enabled | bool | `true` | |
|
||||
| postgresql.primary.persistence.size | string | `"10Gi"` | |
|
||||
| postgresql.primary.podSecurityContext.enabled | bool | `true` | |
|
||||
| postgresql.primary.podSecurityContext.fsGroup | int | `1001` | |
|
||||
| postgresql.primary.podSecurityContext.runAsUser | int | `1001` | |
|
||||
| rbac.enabled | bool | `false` | |
|
||||
| rbac.serviceAccount.additionalLabels | object | `{}` | |
|
||||
| rbac.serviceAccount.annotations | object | `{}` | |
|
||||
| rbac.serviceAccount.enabled | bool | `false` | |
|
||||
| rbac.serviceAccount.name | string | `""` | |
|
||||
| redis.architecture | string | `"standalone"` | |
|
||||
| redis.auth.enabled | bool | `true` | |
|
||||
| redis.auth.existingSecret | string | `"formbricks-app-secrets"` | |
|
||||
| redis.auth.existingSecretPasswordKey | string | `"REDIS_PASSWORD"` | |
|
||||
| redis.enabled | bool | `true` | |
|
||||
| redis.externalRedisUrl | string | `""` | |
|
||||
| redis.fullnameOverride | string | `"formbricks-redis"` | |
|
||||
| redis.master.persistence.enabled | bool | `true` | |
|
||||
| redis.networkPolicy.enabled | bool | `false` | |
|
||||
| secret.enabled | bool | `true` | |
|
||||
| service.additionalLabels | object | `{}` | |
|
||||
| service.annotations | object | `{}` | |
|
||||
| service.enabled | bool | `true` | |
|
||||
| service.ports | list | `[]` | |
|
||||
| service.type | string | `"ClusterIP"` | |
|
||||
| serviceMonitor.additionalLabels | string | `nil` | |
|
||||
| serviceMonitor.annotations | string | `nil` | |
|
||||
| serviceMonitor.enabled | bool | `true` | |
|
||||
| serviceMonitor.endpoints[0].interval | string | `"5s"` | |
|
||||
| serviceMonitor.endpoints[0].path | string | `"/metrics"` | |
|
||||
| serviceMonitor.endpoints[0].port | string | `"metrics"` | |
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------------------------------------------------ | ------ | --------------------------------- | ----------- |
|
||||
| autoscaling.additionalLabels | object | `{}` | |
|
||||
| autoscaling.annotations | object | `{}` | |
|
||||
| autoscaling.enabled | bool | `true` | |
|
||||
| autoscaling.maxReplicas | int | `10` | |
|
||||
| autoscaling.metrics[0].resource.name | string | `"cpu"` | |
|
||||
| autoscaling.metrics[0].resource.target.averageUtilization | int | `60` | |
|
||||
| autoscaling.metrics[0].resource.target.type | string | `"Utilization"` | |
|
||||
| autoscaling.metrics[0].type | string | `"Resource"` | |
|
||||
| autoscaling.metrics[1].resource.name | string | `"memory"` | |
|
||||
| autoscaling.metrics[1].resource.target.averageUtilization | int | `60` | |
|
||||
| autoscaling.metrics[1].resource.target.type | string | `"Utilization"` | |
|
||||
| autoscaling.metrics[1].type | string | `"Resource"` | |
|
||||
| autoscaling.minReplicas | int | `1` | |
|
||||
| componentOverride | string | `""` | |
|
||||
| cronJob.enabled | bool | `false` | |
|
||||
| cronJob.jobs | object | `{}` | |
|
||||
| deployment.additionalLabels | object | `{}` | |
|
||||
| deployment.additionalPodAnnotations | object | `{}` | |
|
||||
| deployment.additionalPodLabels | object | `{}` | |
|
||||
| deployment.affinity | object | `{}` | |
|
||||
| deployment.annotations | object | `{}` | |
|
||||
| deployment.args | list | `[]` | |
|
||||
| deployment.command | list | `[]` | |
|
||||
| deployment.containerSecurityContext.readOnlyRootFilesystem | bool | `true` | |
|
||||
| deployment.containerSecurityContext.runAsNonRoot | bool | `true` | |
|
||||
| deployment.env.EMAIL_VERIFICATION_DISABLED.value | string | `"1"` | |
|
||||
| deployment.env.PASSWORD_RESET_DISABLED.value | string | `"1"` | |
|
||||
| deployment.envFrom | string | `nil` | |
|
||||
| deployment.image.digest | string | `""` | |
|
||||
| deployment.image.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| deployment.image.repository | string | `"ghcr.io/formbricks/formbricks"` | |
|
||||
| deployment.imagePullSecrets | string | `""` | |
|
||||
| deployment.nodeSelector | object | `{}` | |
|
||||
| deployment.ports.http.containerPort | int | `3000` | |
|
||||
| deployment.ports.http.exposed | bool | `true` | |
|
||||
| deployment.ports.http.protocol | string | `"TCP"` | |
|
||||
| deployment.ports.metrics.containerPort | int | `9464` | |
|
||||
| deployment.ports.metrics.exposed | bool | `true` | |
|
||||
| deployment.ports.metrics.protocol | string | `"TCP"` | |
|
||||
| deployment.probes.livenessProbe.failureThreshold | int | `5` | |
|
||||
| deployment.probes.livenessProbe.httpGet.path | string | `"/health"` | |
|
||||
| deployment.probes.livenessProbe.httpGet.port | int | `3000` | |
|
||||
| deployment.probes.livenessProbe.initialDelaySeconds | int | `10` | |
|
||||
| deployment.probes.livenessProbe.periodSeconds | int | `10` | |
|
||||
| deployment.probes.livenessProbe.successThreshold | int | `1` | |
|
||||
| deployment.probes.livenessProbe.timeoutSeconds | int | `5` | |
|
||||
| deployment.probes.readinessProbe.failureThreshold | int | `5` | |
|
||||
| deployment.probes.readinessProbe.httpGet.path | string | `"/health"` | |
|
||||
| deployment.probes.readinessProbe.httpGet.port | int | `3000` | |
|
||||
| deployment.probes.readinessProbe.initialDelaySeconds | int | `10` | |
|
||||
| deployment.probes.readinessProbe.periodSeconds | int | `10` | |
|
||||
| deployment.probes.readinessProbe.successThreshold | int | `1` | |
|
||||
| deployment.probes.readinessProbe.timeoutSeconds | int | `5` | |
|
||||
| deployment.probes.startupProbe.failureThreshold | int | `30` | |
|
||||
| deployment.probes.startupProbe.periodSeconds | int | `10` | |
|
||||
| deployment.probes.startupProbe.tcpSocket.port | int | `3000` | |
|
||||
| deployment.reloadOnChange | bool | `false` | |
|
||||
| deployment.replicas | int | `1` | |
|
||||
| deployment.resources.limits.memory | string | `"2Gi"` | |
|
||||
| deployment.resources.requests.cpu | string | `"1"` | |
|
||||
| deployment.resources.requests.memory | string | `"1Gi"` | |
|
||||
| deployment.revisionHistoryLimit | int | `2` | |
|
||||
| deployment.securityContext | object | `{}` | |
|
||||
| deployment.strategy.type | string | `"RollingUpdate"` | |
|
||||
| deployment.tolerations | list | `[]` | |
|
||||
| deployment.topologySpreadConstraints | list | `[]` | |
|
||||
| enterprise.enabled | bool | `false` | |
|
||||
| enterprise.licenseKey | string | `""` | |
|
||||
| externalSecret.enabled | bool | `false` | |
|
||||
| externalSecret.files | object | `{}` | |
|
||||
| externalSecret.refreshInterval | string | `"1h"` | |
|
||||
| externalSecret.secretStore.kind | string | `"ClusterSecretStore"` | |
|
||||
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
|
||||
| ingress.annotations | object | `{}` | |
|
||||
| ingress.enabled | bool | `false` | |
|
||||
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
|
||||
| ingress.hosts[0].paths[0].path | string | `"/"` | |
|
||||
| ingress.hosts[0].paths[0].pathType | string | `"Prefix"` | |
|
||||
| ingress.hosts[0].paths[0].serviceName | string | `"formbricks"` | |
|
||||
| ingress.ingressClassName | string | `"alb"` | |
|
||||
| nameOverride | string | `""` | |
|
||||
| partOfOverride | string | `""` | |
|
||||
| postgresql.auth.database | string | `"formbricks"` | |
|
||||
| postgresql.auth.existingSecret | string | `"formbricks-app-secrets"` | |
|
||||
| postgresql.auth.secretKeys.adminPasswordKey | string | `"POSTGRES_ADMIN_PASSWORD"` | |
|
||||
| postgresql.auth.secretKeys.userPasswordKey | string | `"POSTGRES_USER_PASSWORD"` | |
|
||||
| postgresql.auth.username | string | `"formbricks"` | |
|
||||
| postgresql.enabled | bool | `true` | |
|
||||
| postgresql.externalDatabaseUrl | string | `""` | |
|
||||
| postgresql.fullnameOverride | string | `"formbricks-postgresql"` | |
|
||||
| postgresql.global.security.allowInsecureImages | bool | `true` | |
|
||||
| postgresql.image.repository | string | `"pgvector/pgvector"` | |
|
||||
| postgresql.image.tag | string | `"0.8.0-pg17"` | |
|
||||
| postgresql.primary.containerSecurityContext.enabled | bool | `true` | |
|
||||
| postgresql.primary.containerSecurityContext.readOnlyRootFilesystem | bool | `false` | |
|
||||
| postgresql.primary.containerSecurityContext.runAsUser | int | `1001` | |
|
||||
| postgresql.primary.networkPolicy.enabled | bool | `false` | |
|
||||
| postgresql.primary.persistence.enabled | bool | `true` | |
|
||||
| postgresql.primary.persistence.size | string | `"10Gi"` | |
|
||||
| postgresql.primary.podSecurityContext.enabled | bool | `true` | |
|
||||
| postgresql.primary.podSecurityContext.fsGroup | int | `1001` | |
|
||||
| postgresql.primary.podSecurityContext.runAsUser | int | `1001` | |
|
||||
| rbac.enabled | bool | `false` | |
|
||||
| rbac.serviceAccount.additionalLabels | object | `{}` | |
|
||||
| rbac.serviceAccount.annotations | object | `{}` | |
|
||||
| rbac.serviceAccount.enabled | bool | `false` | |
|
||||
| rbac.serviceAccount.name | string | `""` | |
|
||||
| redis.architecture | string | `"standalone"` | |
|
||||
| redis.auth.enabled | bool | `true` | |
|
||||
| redis.auth.existingSecret | string | `"formbricks-app-secrets"` | |
|
||||
| redis.auth.existingSecretPasswordKey | string | `"REDIS_PASSWORD"` | |
|
||||
| redis.enabled | bool | `true` | |
|
||||
| redis.externalRedisUrl | string | `""` | |
|
||||
| redis.fullnameOverride | string | `"formbricks-redis"` | |
|
||||
| redis.master.persistence.enabled | bool | `true` | |
|
||||
| redis.networkPolicy.enabled | bool | `false` | |
|
||||
| secret.enabled | bool | `true` | |
|
||||
| service.additionalLabels | object | `{}` | |
|
||||
| service.annotations | object | `{}` | |
|
||||
| service.enabled | bool | `true` | |
|
||||
| service.ports | list | `[]` | |
|
||||
| service.type | string | `"ClusterIP"` | |
|
||||
| serviceMonitor.additionalLabels | string | `nil` | |
|
||||
| serviceMonitor.annotations | string | `nil` | |
|
||||
| serviceMonitor.enabled | bool | `true` | |
|
||||
| serviceMonitor.endpoints[0].interval | string | `"5s"` | |
|
||||
| serviceMonitor.endpoints[0].path | string | `"/metrics"` | |
|
||||
| serviceMonitor.endpoints[0].port | string | `"metrics"` | |
|
||||
|
||||
---
|
||||
|
||||
Autogenerated from chart metadata using [helm-docs v1.14.2](https://github.com/norwoodj/helm-docs/releases/v1.14.2)
|
||||
|
||||
@@ -8,15 +8,6 @@ It also truncates the name to a maximum of 63 characters and removes trailing hy
|
||||
{{- end }}
|
||||
|
||||
|
||||
{{/*
|
||||
Hub resource name: base name truncated to 59 chars then "-hub" so the suffix is never lost (63 char limit).
|
||||
*/}}
|
||||
{{- define "formbricks.hubname" -}}
|
||||
{{- $base := include "formbricks.name" . | trunc 59 | trimSuffix "-" }}
|
||||
{{- printf "%s-hub" $base | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
|
||||
{{/*
|
||||
Define the application version to be used in labels.
|
||||
The version is taken from `.Values.deployment.image.tag` if provided, otherwise it defaults to `.Chart.Version`.
|
||||
@@ -94,17 +85,9 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
|
||||
{{- default .Release.Namespace .Values.namespaceOverride -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "formbricks.appSecretName" -}}
|
||||
{{- printf "%s-app-secrets" (include "formbricks.name" .) -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.hubSecretName" -}}
|
||||
{{- default (include "formbricks.appSecretName" .) .Values.hub.existingSecret -}}
|
||||
{{- end }}
|
||||
|
||||
|
||||
{{- define "formbricks.postgresAdminPassword" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
|
||||
{{- if and $secret (index $secret.data "POSTGRES_ADMIN_PASSWORD") }}
|
||||
{{- index $secret.data "POSTGRES_ADMIN_PASSWORD" | b64dec -}}
|
||||
{{- else }}
|
||||
@@ -113,7 +96,7 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.postgresUserPassword" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
|
||||
{{- if and $secret (index $secret.data "POSTGRES_USER_PASSWORD") }}
|
||||
{{- index $secret.data "POSTGRES_USER_PASSWORD" | b64dec -}}
|
||||
{{- else }}
|
||||
@@ -122,7 +105,7 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.redisPassword" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
|
||||
{{- if and $secret (index $secret.data "REDIS_PASSWORD") }}
|
||||
{{- index $secret.data "REDIS_PASSWORD" | b64dec -}}
|
||||
{{- else }}
|
||||
@@ -131,7 +114,7 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.cronSecret" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
|
||||
{{- if $secret }}
|
||||
{{- index $secret.data "CRON_SECRET" | b64dec -}}
|
||||
{{- else }}
|
||||
@@ -140,7 +123,7 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.encryptionKey" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
|
||||
{{- if $secret }}
|
||||
{{- index $secret.data "ENCRYPTION_KEY" | b64dec -}}
|
||||
{{- else }}
|
||||
@@ -149,19 +132,10 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.nextAuthSecret" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (printf "%s-app-secrets" (include "formbricks.name" .))) }}
|
||||
{{- if $secret }}
|
||||
{{- index $secret.data "NEXTAUTH_SECRET" | b64dec -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 32 -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.hubApiKey" -}}
|
||||
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
|
||||
{{- if and $secret (index $secret.data "HUB_API_KEY") }}
|
||||
{{- index $secret.data "HUB_API_KEY" | b64dec -}}
|
||||
{{- else }}
|
||||
{{- randAlphaNum 32 -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
@@ -131,10 +131,6 @@ spec:
|
||||
- name: SKIP_STARTUP_MIGRATION
|
||||
value: "true"
|
||||
{{- end }}
|
||||
{{- if not (hasKey .Values.deployment.env "HUB_API_URL") }}
|
||||
- name: HUB_API_URL
|
||||
value: "http://{{ include "formbricks.hubname" . }}:8080"
|
||||
{{- end }}
|
||||
{{- range $key, $value := .Values.deployment.env }}
|
||||
- name: {{ include "formbricks.tplvalues.render" ( dict "value" $key "context" $ ) }}
|
||||
{{- if kindIs "string" $value }}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
{{- if not .Values.hub.enabled }}
|
||||
{{- fail "hub.enabled=false is not supported in Formbricks 5; Hub is mandatory." }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubname" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
spec:
|
||||
replicas: {{ .Values.hub.replicas | default 1 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub
|
||||
spec:
|
||||
{{- if .Values.deployment.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
initContainers:
|
||||
- name: hub-migrate
|
||||
image: {{ .Values.hub.image.repository }}:{{ .Values.hub.image.tag | default "latest" }}
|
||||
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
/usr/local/bin/goose -dir /app/migrations postgres "$DATABASE_URL" up && \
|
||||
/usr/local/bin/river migrate-up --database-url "$DATABASE_URL"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ include "formbricks.hubSecretName" . }}
|
||||
containers:
|
||||
- name: hub
|
||||
image: {{ .Values.hub.image.repository }}:{{ .Values.hub.image.tag | default "latest" }}
|
||||
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ include "formbricks.hubSecretName" . }}
|
||||
env:
|
||||
- name: API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "formbricks.hubSecretName" . }}
|
||||
key: HUB_API_KEY
|
||||
{{- range $key, $value := .Values.hub.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.hub.resources }}
|
||||
resources:
|
||||
{{- toYaml .Values.hub.resources | nindent 12 }}
|
||||
{{- end }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
failureThreshold: 5
|
||||
timeoutSeconds: 5
|
||||
successThreshold: 1
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
failureThreshold: 5
|
||||
timeoutSeconds: 5
|
||||
successThreshold: 1
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
failureThreshold: 30
|
||||
periodSeconds: 10
|
||||
@@ -1,54 +0,0 @@
|
||||
{{- if not .Values.hub.enabled }}
|
||||
{{- fail "hub.enabled=false is not supported in Formbricks 5; Hub is mandatory." }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubname" . }}-migration
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-migration
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
annotations:
|
||||
helm.sh/hook: pre-upgrade
|
||||
helm.sh/hook-weight: "-5"
|
||||
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
|
||||
spec:
|
||||
ttlSecondsAfterFinished: {{ .Values.hub.migration.ttlSecondsAfterFinished | default 300 }}
|
||||
backoffLimit: {{ .Values.hub.migration.backoffLimit | default 3 }}
|
||||
activeDeadlineSeconds: {{ .Values.hub.migration.activeDeadlineSeconds | default 900 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-migration
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
{{- if .Values.deployment.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: hub-migrate
|
||||
image: {{ .Values.hub.image.repository }}:{{ .Values.hub.image.tag | default "latest" }}
|
||||
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
|
||||
securityContext:
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
/usr/local/bin/goose -dir /app/migrations postgres "$DATABASE_URL" up && \
|
||||
/usr/local/bin/river migrate-up --database-url "$DATABASE_URL"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ include "formbricks.hubSecretName" . }}
|
||||
@@ -1,25 +0,0 @@
|
||||
{{- if not .Values.hub.enabled }}
|
||||
{{- fail "hub.enabled=false is not supported in Formbricks 5; Hub is mandatory." }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubname" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
@@ -4,13 +4,11 @@
|
||||
{{- $postgresUserPassword := include "formbricks.postgresUserPassword" . }}
|
||||
{{- $redisPassword := include "formbricks.redisPassword" . }}
|
||||
{{- $webappUrl := required "formbricks.webappUrl is required. Set it to your Formbricks instance URL (e.g., https://formbricks.example.com)" .Values.formbricks.webappUrl }}
|
||||
{{- $hubApiKey := include "formbricks.hubApiKey" . }}
|
||||
{{- $includeHubApiKeyInAppSecret := or (not .Values.hub.existingSecret) (eq .Values.hub.existingSecret (include "formbricks.appSecretName" .)) }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "formbricks.appSecretName" . }}
|
||||
name: {{ template "formbricks.name" . }}-app-secrets
|
||||
labels:
|
||||
{{- include "formbricks.labels" . | nindent 4 }}
|
||||
data:
|
||||
@@ -30,9 +28,6 @@ data:
|
||||
{{- else }}
|
||||
DATABASE_URL: {{ .Values.postgresql.externalDatabaseUrl | b64enc }}
|
||||
{{- end }}
|
||||
{{- if $includeHubApiKeyInAppSecret }}
|
||||
HUB_API_KEY: {{ $hubApiKey | b64enc }}
|
||||
{{- end }}
|
||||
CRON_SECRET: {{ include "formbricks.cronSecret" . | b64enc }}
|
||||
ENCRYPTION_KEY: {{ include "formbricks.encryptionKey" . | b64enc }}
|
||||
NEXTAUTH_SECRET: {{ include "formbricks.nextAuthSecret" . | b64enc }}
|
||||
|
||||
@@ -340,43 +340,6 @@ serviceMonitor:
|
||||
path: /metrics
|
||||
port: metrics
|
||||
|
||||
##########################################################
|
||||
# Hub API Configuration
|
||||
# Formbricks Hub image: ghcr.io/formbricks/hub
|
||||
##########################################################
|
||||
hub:
|
||||
# Hub is mandatory in Formbricks 5. Keep this enabled.
|
||||
enabled: true
|
||||
replicas: 1
|
||||
|
||||
image:
|
||||
repository: "ghcr.io/formbricks/hub"
|
||||
# Pin to a semver tag for reproducible deployments; update on each Hub release.
|
||||
tag: "1.0.0"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Optional override for the secret Hub reads from.
|
||||
# Defaults to the generated app secret (<release>-app-secrets), which contains DATABASE_URL and HUB_API_KEY.
|
||||
# If you set this, the custom secret must provide DATABASE_URL and HUB_API_KEY.
|
||||
existingSecret: ""
|
||||
|
||||
# Optional env vars (non-secret). Use existingSecret for secret values such as DATABASE_URL and HUB_API_KEY.
|
||||
env: {}
|
||||
|
||||
# Upgrade migration job runs goose + river before Helm upgrades Hub resources.
|
||||
# Fresh installs run the same migrations through the Hub deployment init container.
|
||||
migration:
|
||||
ttlSecondsAfterFinished: 300
|
||||
backoffLimit: 3
|
||||
activeDeadlineSeconds: 900
|
||||
|
||||
resources:
|
||||
limits:
|
||||
memory: 512Mi
|
||||
requests:
|
||||
memory: 256Mi
|
||||
cpu: "100m"
|
||||
|
||||
##########################################################
|
||||
# PostgreSQL Configuration
|
||||
##########################################################
|
||||
@@ -389,7 +352,7 @@ postgresql:
|
||||
fullnameOverride: "formbricks-postgresql"
|
||||
image:
|
||||
repository: pgvector/pgvector
|
||||
tag: pg17
|
||||
tag: 0.8.0-pg17
|
||||
auth:
|
||||
username: formbricks
|
||||
database: formbricks
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
services:
|
||||
# PostgreSQL must load the vector library so Hub (and Formbricks) can use the pgvector extension.
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg18
|
||||
image: pgvector/pgvector:pg17
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql
|
||||
- postgres:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_DB=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
command: >
|
||||
postgres
|
||||
-c shared_preload_libraries=vector
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
|
||||
mailhog:
|
||||
image: arjenz/mailhog
|
||||
@@ -46,40 +36,6 @@ services:
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
|
||||
# Run Hub DB migrations (goose + river) before the API starts. Idempotent; runs on every compose up.
|
||||
hub-migrate:
|
||||
image: ghcr.io/formbricks/hub:latest
|
||||
restart: "no"
|
||||
entrypoint: ["sh", "-c"]
|
||||
command:
|
||||
[
|
||||
'if [ -x /usr/local/bin/goose ] && [ -x /usr/local/bin/river ]; then /usr/local/bin/goose -dir /app/migrations postgres "$$DATABASE_URL" up && /usr/local/bin/river migrate-up --database-url "$$DATABASE_URL"; else echo ''Migration tools (goose/river) not in image.''; exit 1; fi',
|
||||
]
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# Formbricks Hub API (ghcr.io/formbricks/hub). Shares the same Postgres database as Formbricks by default.
|
||||
hub:
|
||||
image: ghcr.io/formbricks/hub:latest
|
||||
depends_on:
|
||||
hub-migrate:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
API_KEY: ${HUB_API_KEY:-dev-api-key}
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
# Explicit Postgres env so migrations and any libpq fallback use the service host, not localhost
|
||||
PGHOST: postgres
|
||||
PGPORT: "5432"
|
||||
PGUSER: postgres
|
||||
PGPASSWORD: postgres
|
||||
PGDATABASE: postgres
|
||||
PGSSLMODE: disable
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
driver: local
|
||||
|
||||
@@ -27,13 +27,3 @@ The script will prompt you for the following information:
|
||||
3. **Domain Name**: Enter the domain name that Traefik will use to create the SSL certificate and forward requests to Formbricks.
|
||||
|
||||
That's it! After running the command and providing the required information, visit the domain name you entered, and you should see the Formbricks home wizard!
|
||||
|
||||
## Formbricks Hub
|
||||
|
||||
The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (`ghcr.io/formbricks/hub`). Hub shares the same database as Formbricks by default.
|
||||
|
||||
- **Migrations**: A `hub-migrate` service runs Hub's database migrations (goose + river) before the Hub API starts. It runs on every `docker compose up` and is idempotent.
|
||||
- **Production** (`docker/docker-compose.yml`): Set `HUB_API_KEY` (required). `HUB_API_URL` defaults to `http://hub:8080` so the Formbricks app can reach Hub inside the compose network. Override `HUB_DATABASE_URL` only if you want Hub to use a separate database.
|
||||
- **Development** (`docker-compose.dev.yml`): Hub uses the same Postgres database; `HUB_API_KEY` defaults to `dev-api-key` (override with `HUB_API_KEY`) and the local Hub URL is `http://localhost:8080`.
|
||||
|
||||
In development, Hub is exposed locally on port **8080**. In production Docker Compose, Hub stays internal to the compose network and is reached via `http://hub:8080`.
|
||||
|
||||
@@ -29,15 +29,6 @@ x-environment: &environment
|
||||
# To use external Redis/Valkey: remove the redis service below and update this URL
|
||||
REDIS_URL: redis://redis:6379
|
||||
|
||||
# Formbricks Hub (port 8080): API key required. Use e.g. openssl rand -hex 32
|
||||
HUB_API_KEY:
|
||||
|
||||
# Base URL the Formbricks app uses to reach Hub. Defaults to the internal Hub service.
|
||||
HUB_API_URL: ${HUB_API_URL:-http://hub:8080}
|
||||
|
||||
# Hub database URL (optional). Default: same Postgres as Formbricks. Set only if Hub uses a separate DB.
|
||||
# HUB_DATABASE_URL:
|
||||
|
||||
# Set the minimum log level(debug, info, warn, error, fatal)
|
||||
# LOG_LEVEL: info
|
||||
|
||||
@@ -211,7 +202,7 @@ x-environment: &environment
|
||||
services:
|
||||
postgres:
|
||||
restart: always
|
||||
image: pgvector/pgvector:pg18
|
||||
image: pgvector/pgvector:pg17
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
environment:
|
||||
@@ -254,31 +245,6 @@ services:
|
||||
- ./saml-connection:/home/nextjs/apps/web/saml-connection
|
||||
<<: *environment
|
||||
|
||||
# Run Hub DB migrations (goose + river) before the API starts. Uses same image; migrations are idempotent.
|
||||
hub-migrate:
|
||||
image: ghcr.io/formbricks/hub:latest
|
||||
restart: "no"
|
||||
entrypoint: ["sh", "-c"]
|
||||
command: ["if [ -x /usr/local/bin/goose ] && [ -x /usr/local/bin/river ]; then /usr/local/bin/goose -dir /app/migrations postgres \"$$DATABASE_URL\" up && /usr/local/bin/river migrate-up --database-url \"$$DATABASE_URL\"; else echo 'Migration tools (goose/river) not in image.'; exit 1; fi"]
|
||||
environment:
|
||||
DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/formbricks?sslmode=disable}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# Formbricks Hub API (ghcr.io/formbricks/hub). Set HUB_API_KEY. By default shares the Formbricks database; set HUB_DATABASE_URL to use a separate DB.
|
||||
hub:
|
||||
restart: always
|
||||
image: ghcr.io/formbricks/hub:latest
|
||||
depends_on:
|
||||
hub-migrate:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
API_KEY: ${HUB_API_KEY:?HUB_API_KEY is required to run Hub}
|
||||
DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/formbricks?sslmode=disable}
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
driver: local
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
# Plan: Deprecate Environments in Formbricks
|
||||
|
||||
**Issue**: https://github.com/formbricks/internal/issues/1501
|
||||
|
||||
## Context
|
||||
|
||||
Formbricks currently has a 4-level hierarchy: **Organization → Project → Environment (prod/dev) → Resources**. The "Environment" layer adds complexity with minimal value — the only real difference between prod and dev is separate API keys and a UI badge. The UI already calls "Project" a "Workspace".
|
||||
|
||||
**Goal**: Collapse the Environment layer so resources live directly under Project. The production environment merges into the workspace identity. Dev environments with data become separate new workspaces.
|
||||
|
||||
**Key decisions**:
|
||||
- DB model stays as `Project` (no table rename)
|
||||
- SDK will accept `workspaceId` as new param, `environmentId` as deprecated alias
|
||||
- Dev environments with data get promoted to separate workspaces
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
```
|
||||
Organization
|
||||
└── Project ("Workspace" in UI)
|
||||
├── Environment (production) ──→ surveys, contacts, webhooks, tags, ...
|
||||
└── Environment (development) ──→ surveys, contacts, webhooks, tags, ...
|
||||
```
|
||||
|
||||
Every project always has exactly 2 environments. The only differences between them:
|
||||
- Separate data (contacts, responses, attributes, integrations, webhooks, segments, etc.)
|
||||
- Separate API keys (`ApiKeyEnvironment` grants per-environment permissions)
|
||||
- A red warning banner in the dev UI, plus an environment switcher breadcrumb
|
||||
|
||||
Key metrics:
|
||||
- **564 files** in `apps/web` reference `environmentId`
|
||||
- **52 files** in `packages` reference `environmentId`
|
||||
- **68+ route directories** under `/environments/[environmentId]/`
|
||||
- **22 API endpoint directories** keyed by `[environmentId]`
|
||||
- **8 resource tables** FK to Environment: `Survey`, `Contact`, `ActionClass`, `ContactAttributeKey`, `Webhook`, `Tag`, `Segment`, `Integration`
|
||||
- **SDK** requires `environmentId` to initialize, all client APIs use `/api/v1/client/[environmentId]/...`
|
||||
- **Storage** paths: `private/${environmentId}/${fileName}`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Add `projectId` Column to All Environment-Owned Models (PR 1 — Small, Low Risk)
|
||||
|
||||
Add an **optional** `projectId` column alongside the existing `environmentId` on every model that currently only references Environment.
|
||||
|
||||
**Why**: Today, Survey has `environmentId` pointing to Environment, and you have to join through Environment to reach Project. We need Survey to point directly to Project. But we can't just switch the FK in one shot — that would break everything. So we add a new nullable `projectId` column alongside the existing `environmentId`. No code changes, no runtime impact. Just schema preparation.
|
||||
|
||||
**Modify**: `packages/database/schema.prisma`
|
||||
- Add `projectId String?` + FK to Project + index to: `Survey`, `Contact`, `ActionClass`, `ContactAttributeKey`, `Webhook`, `Tag`, `Segment`, `Integration`
|
||||
- Add reverse relations on the `Project` model
|
||||
- New Prisma migration file
|
||||
|
||||
No code changes. No runtime behavior change. All new columns are NULL.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Backfill `projectId` (PR 2 — Small, Medium Risk)
|
||||
|
||||
Data migration to populate `projectId` on every existing row.
|
||||
|
||||
**Why**: The new `projectId` columns are all NULL. We need to populate them by joining through the Environment table: `Survey.environmentId → Environment.id → Environment.projectId`. After this, every row has both `environmentId` (old) and `projectId` (new) filled in, but the app still only reads `environmentId`.
|
||||
|
||||
```sql
|
||||
UPDATE "Survey" s SET "projectId" = e."projectId"
|
||||
FROM "Environment" e WHERE s."environmentId" = e."id" AND s."projectId" IS NULL;
|
||||
-- Repeat for all 8 tables
|
||||
```
|
||||
|
||||
**Create**: Migration script (idempotent — only updates rows where `projectId IS NULL`)
|
||||
|
||||
App behavior unchanged. New columns now populated but not yet read.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Dual-Write (PR 3 — Large, Medium Risk)
|
||||
|
||||
All create/update operations write both `environmentId` AND `projectId`.
|
||||
|
||||
**Why**: New rows created after the backfill would still have `projectId = NULL` because the app code doesn't know about the new column yet. We update every `prisma.survey.create(...)`, `prisma.contact.create(...)`, etc. to write both `environmentId` and `projectId`. Now every new row gets both values. Old code still reads `environmentId` — nothing breaks.
|
||||
|
||||
**Key files to modify**:
|
||||
- `apps/web/lib/survey/service.ts` — `createSurvey`
|
||||
- `apps/web/lib/environment/service.ts` — `createEnvironment` (creates default ContactAttributeKeys)
|
||||
- `apps/web/modules/projects/settings/lib/project.ts` — `createProject`
|
||||
- `apps/web/modules/survey/list/lib/survey.ts` — `copySurveyToOtherEnvironment`
|
||||
- `apps/web/modules/survey/components/template-list/lib/survey.ts` — `createSurvey`
|
||||
- `apps/web/lib/actionClass/service.ts` — `createActionClass`
|
||||
- `apps/web/modules/survey/editor/lib/action-class.ts` — `createActionClass`
|
||||
- `apps/web/modules/ee/contacts/lib/contacts.ts` — `processCsvRecord`, `createMissingAttributeKeys`
|
||||
- `apps/web/modules/ee/contacts/api/v2/management/contacts/lib/contact.ts` — `createContact`
|
||||
- `apps/web/app/api/v1/client/[environmentId]/displays/lib/display.ts` — `createDisplay` (creates contacts)
|
||||
- `apps/web/modules/ee/contacts/lib/contact-attribute-keys.ts` — `createContactAttributeKey`
|
||||
- `apps/web/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key.ts` — `createContactAttributeKey`
|
||||
- `apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/lib/contact-attribute-keys.ts` — `createContactAttributeKey`
|
||||
- `apps/web/modules/integrations/webhooks/lib/webhook.ts` — `createWebhook`
|
||||
- `apps/web/modules/api/v2/management/webhooks/lib/webhook.ts` — `createWebhook`
|
||||
- `apps/web/app/api/v1/webhooks/lib/webhook.ts` — `createWebhook`
|
||||
- `apps/web/lib/tag/service.ts` — `createTag`
|
||||
- `apps/web/modules/ee/contacts/segments/lib/segments.ts` — `createSegment`, `cloneSegment`, `resetSegmentInSurvey`
|
||||
- `apps/web/lib/integration/service.ts` — `createOrUpdateIntegration`
|
||||
|
||||
Pattern:
|
||||
```typescript
|
||||
// Resolve environmentId to projectId using existing getEnvironment()
|
||||
const environment = await getEnvironment(environmentId);
|
||||
const projectId = environment.projectId;
|
||||
await prisma.survey.create({ data: { environmentId, projectId, ...rest } });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Switch Internal Reads to `projectId` (PR 4 — Very Large, High Risk)
|
||||
|
||||
Change internal (non-API) queries from `WHERE environmentId = ?` to `WHERE projectId = ?`.
|
||||
|
||||
**Why**: This is the actual migration. Every query that says `WHERE environmentId = X` changes to `WHERE projectId = X`. Functions like `getSurveys(environmentId)` become `getSurveys(projectId)`. The layout at `/environments/[environmentId]/layout.tsx` resolves the environmentId from the URL to a projectId early on and passes projectId downstream. After this phase, the app internally thinks in terms of projects, not environments, even though URLs still say `[environmentId]`.
|
||||
|
||||
**Key files**:
|
||||
- `apps/web/modules/survey/list/lib/survey.ts` — `getSurveys(environmentId)` → `getSurveys(projectId)`
|
||||
- `apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts` — `getEnvironmentStateData`
|
||||
- `apps/web/modules/environments/lib/utils.ts` — `getEnvironmentAuth`, `getEnvironmentLayoutData`
|
||||
- `apps/web/app/(app)/environments/[environmentId]/layout.tsx` — resolve `projectId` early, pass to context
|
||||
- `apps/web/app/(app)/environments/[environmentId]/context/environment-context.tsx` — add `projectId`
|
||||
- All page server components that pass `environmentId` to service functions
|
||||
|
||||
URL still has `[environmentId]`. Each page resolves `environmentId → projectId` at the top.
|
||||
|
||||
**This PR can be split further** by migrating one resource type at a time (surveys first, then contacts, then actions, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Client API Backwards Compatibility (PR 5 — Medium, Medium Risk)
|
||||
|
||||
Make `/api/v1/client/[environmentId]/...` and `/api/v2/client/[environmentId]/...` accept either an `environmentId` or a `projectId`.
|
||||
|
||||
**Why**: The SDK sends requests to `/api/v1/client/[environmentId]/...`. Existing deployed SDKs will keep sending environmentIds. New SDKs will send projectIds. Each route handler needs to accept either and resolve to a projectId internally. This ensures old SDK versions don't break.
|
||||
|
||||
**Add fallback resolution at top of each route handler**:
|
||||
```typescript
|
||||
// Try Environment table first, fall back to Project table
|
||||
let projectId: string;
|
||||
const environment = await prisma.environment.findUnique({ where: { id: params.environmentId } });
|
||||
if (environment) {
|
||||
projectId = environment.projectId;
|
||||
} else {
|
||||
projectId = params.environmentId; // caller passed a projectId directly
|
||||
}
|
||||
```
|
||||
|
||||
**Files**:
|
||||
- `apps/web/app/api/v1/client/[environmentId]/environment/route.ts`
|
||||
- `apps/web/app/api/v1/client/[environmentId]/displays/route.ts`
|
||||
- `apps/web/app/api/v1/client/[environmentId]/responses/route.ts`
|
||||
- `apps/web/app/api/v1/client/[environmentId]/storage/route.ts`
|
||||
- `apps/web/app/api/v1/client/[environmentId]/user/route.ts`
|
||||
- `apps/web/app/api/v2/client/[environmentId]/` — all routes
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Management API + API Key Migration (PR 6 — Medium, Medium Risk)
|
||||
|
||||
**Why**: The `ApiKeyEnvironment` model grants per-environment permissions. API keys used by integrations (Zapier, Make, etc.) reference environmentIds. These need to work at the project level. The management API endpoints that accept `environmentId` in request bodies need to also accept `projectId`.
|
||||
|
||||
- Modify `ApiKeyEnvironment` to also support project-level permissions (or add `projectId` to the model)
|
||||
- Update `apps/web/app/api/v1/auth.ts` — `authenticateRequest` resolves environment permissions to project
|
||||
- Management route handlers accept `environmentId` OR `projectId` in request bodies
|
||||
- API key management UI in `modules/organization/settings/api-keys/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Storage Path Migration (PR 7 — Medium, Medium Risk)
|
||||
|
||||
**Why**: Uploaded files are stored at paths like `private/{environmentId}/{fileName}`. New uploads should use `{projectId}/...`, but old files still live at the old paths. Downloads need to check both locations for backward compatibility.
|
||||
|
||||
- New uploads use `{projectId}/{accessType}/{fileName}`
|
||||
- Downloads check both `{projectId}/...` and `{environmentId}/...` paths for backwards compat
|
||||
- `apps/web/modules/storage/service.ts`
|
||||
- `apps/web/app/storage/[environmentId]/[accessType]/[fileName]/route.ts`
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Dev Environment Data Migration (PR 8 — Large, High Risk)
|
||||
|
||||
**Why**: Currently each project has a prod and dev environment. After the migration, there's no "environment" concept — just projects. Dev environments with no data can be discarded. Dev environments with data need to be promoted into new standalone projects so that data isn't lost.
|
||||
|
||||
For each Project with a development Environment that has data:
|
||||
1. Create new Project named `{name} (Dev)` in the same Organization
|
||||
2. Create a production Environment for the new Project
|
||||
3. Re-parent all dev environment resources to the new Project (update `projectId`)
|
||||
4. Re-parent resources to the new production environment (update `environmentId`)
|
||||
|
||||
For development environments with NO data: leave as-is (will be cleaned up later).
|
||||
|
||||
**Create**: Idempotent migration script in `packages/database/migration/` or `scripts/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: New `/workspaces/[projectId]/` Routes + Redirects (PR 9 — Very Large, High Risk)
|
||||
|
||||
**Why**: The URL currently says `/environments/[environmentId]/surveys/...`. After the migration, it should say `/workspaces/[projectId]/surveys/...`. This phase creates the new route group mirroring the old structure, removes the environment switcher breadcrumb, and adds redirects so old bookmarked URLs still work.
|
||||
|
||||
- Create `/apps/web/app/(app)/workspaces/[projectId]/` route group mirroring the environments structure
|
||||
- New layout resolves `projectId` directly
|
||||
- Old `/environments/[environmentId]/...` routes redirect to `/workspaces/{projectId}/...`
|
||||
- Update `apps/web/app/page.tsx` to redirect to workspace URLs
|
||||
- Remove environment switcher breadcrumb
|
||||
|
||||
**Can be split** into sub-PRs: layout first, then surveys, then settings, etc.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Make `projectId` NOT NULL (PR 10 — Small, Low Risk)
|
||||
|
||||
**Why**: At this point, every row has `projectId` populated (backfill + dual-write), and all reads use `projectId`. Now we can safely make it required in the schema. This is a safety net — the DB will reject any row that somehow doesn't have a projectId.
|
||||
|
||||
```sql
|
||||
ALTER TABLE "Survey" ALTER COLUMN "projectId" SET NOT NULL;
|
||||
-- Repeat for all 8 tables
|
||||
```
|
||||
|
||||
Pre-check: verify no NULL values remain.
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: JS SDK Update (PR 11 — Medium, Low Risk)
|
||||
|
||||
**Why**: Add `workspaceId` as the new init parameter. `environmentId` keeps working as a deprecated alias. Existing integrations don't break.
|
||||
|
||||
- `packages/js-core/src/types/config.ts` — add `workspaceId` to `TConfigInput`
|
||||
- `packages/js-core/src/lib/common/setup.ts` — accept `workspaceId`, fall back to `environmentId`
|
||||
- `environmentId` continues working as deprecated alias indefinitely
|
||||
|
||||
```typescript
|
||||
// New:
|
||||
formbricks.init({ workspaceId: "cxxx", appUrl: "..." })
|
||||
// Old (still works):
|
||||
formbricks.init({ environmentId: "cxxx", appUrl: "..." })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
After each PR:
|
||||
1. `pnpm build` passes
|
||||
2. Existing tests pass (`pnpm test`)
|
||||
3. Manual smoke test: create survey, submit response, check dashboard
|
||||
4. SDK initialization works with existing `environmentId`
|
||||
|
||||
After full migration:
|
||||
- Old environment URLs redirect correctly
|
||||
- Old API keys work
|
||||
- Old SDK `environmentId` init works
|
||||
- New `workspaceId` SDK init works
|
||||
- Storage files accessible via both old and new paths
|
||||
- Dev environments with data are separate workspaces
|
||||
|
||||
---
|
||||
|
||||
## PR Summary
|
||||
|
||||
| PR | Phase | Description | Size | Risk |
|
||||
|----|-------|-------------|------|------|
|
||||
| 1 | 1 | Add nullable `projectId` columns | S | Low |
|
||||
| 2 | 2 | Backfill `projectId` data migration | S | Med |
|
||||
| 3 | 3 | Dual-write `projectId` on all creates | L | Med |
|
||||
| 4 | 4 | Switch reads to `projectId` | XL | High |
|
||||
| 5 | 5 | Client API backwards compat | M | Med |
|
||||
| 6 | 6 | Management API + API key migration | M | Med |
|
||||
| 7 | 7 | Storage path migration | M | Med |
|
||||
| 8 | 8 | Dev environment → workspace promotion | L | High |
|
||||
| 9 | 9 | New workspace routes + redirects | XL | High |
|
||||
| 10 | 10 | Make `projectId` NOT NULL | S | Low |
|
||||
| 11 | 11 | JS SDK `workspaceId` support | M | Low |
|
||||
@@ -32,6 +32,7 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
|
||||
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
|
||||
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
|
||||
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
|
||||
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to 1. | optional | |
|
||||
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
|
||||
| MAIL_FROM_NAME | Email name/title to send emails from. | optional (required if email services are to be enabled) | |
|
||||
@@ -77,14 +78,4 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| AUDIT_LOG_ENABLED | Set this to 1 to enable audit logging. Requires Redis to be configured with the REDIS_URL env variable. | optional | 0 |
|
||||
| AUDIT_LOG_GET_USER_IP | Set to 1 to include user IP addresses in audit logs from request headers | optional | 0 |
|
||||
|
||||
#### Formbricks Hub
|
||||
|
||||
When running the stack with [Formbricks Hub](https://github.com/formbricks/hub) (for example via Docker Compose or Helm), the following variables apply:
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------ | -------------------------- | ----------------------------------------------------- |
|
||||
| HUB_API_KEY | API key used by the Formbricks Hub API (port 8080). | required | (e.g. `openssl rand -hex 32`) |
|
||||
| HUB_API_URL | Base URL the Formbricks app uses to call Hub. Use `http://localhost:8080` locally. | required | `http://localhost:8080` in local dev |
|
||||
| HUB_DATABASE_URL | PostgreSQL connection URL for Hub. Omit to use the same database as Formbricks. | optional | Same as Formbricks `DATABASE_URL` (shared database) |
|
||||
|
||||
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we'll try our best to work out a solution with you.
|
||||
|
||||
@@ -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). 😃
|
||||
|
||||
---
|
||||
|
||||
@@ -47,9 +47,7 @@ export const xmSegmentMigration: MigrationScript = {
|
||||
id: "s644oyyqccstfdeejc4fluye",
|
||||
name: "20241209110456_xm_segment_migration",
|
||||
run: async ({ tx }) => {
|
||||
const allSegments = await tx.segment.findMany({
|
||||
select: { id: true, filters: true },
|
||||
});
|
||||
const allSegments = await tx.segment.findMany();
|
||||
const updationPromises = [];
|
||||
for (const segment of allSegments) {
|
||||
updationPromises.push(
|
||||
@@ -58,7 +56,6 @@ export const xmSegmentMigration: MigrationScript = {
|
||||
data: {
|
||||
filters: findAndReplace(segment.filters),
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -1,80 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ActionClass" ADD COLUMN "projectId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Contact" ADD COLUMN "projectId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ContactAttributeKey" ADD COLUMN "projectId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Integration" ADD COLUMN "projectId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Segment" ADD COLUMN "projectId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "projectId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Tag" ADD COLUMN "projectId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Webhook" ADD COLUMN "projectId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ApiKeyEnvironment" ADD COLUMN "projectId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActionClass_projectId_createdAt_idx" ON "ActionClass"("projectId", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Contact_projectId_idx" ON "Contact"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ContactAttributeKey_projectId_createdAt_idx" ON "ContactAttributeKey"("projectId", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Integration_projectId_idx" ON "Integration"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Segment_projectId_idx" ON "Segment"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Survey_projectId_updatedAt_idx" ON "Survey"("projectId", "updated_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Tag_projectId_idx" ON "Tag"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Webhook_projectId_idx" ON "Webhook"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ApiKeyEnvironment_projectId_idx" ON "ApiKeyEnvironment"("projectId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContactAttributeKey" ADD CONSTRAINT "ContactAttributeKey_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Contact" ADD CONSTRAINT "Contact_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Tag" ADD CONSTRAINT "Tag_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActionClass" ADD CONSTRAINT "ActionClass_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Integration" ADD CONSTRAINT "Integration_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Segment" ADD CONSTRAINT "Segment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ApiKeyEnvironment" ADD CONSTRAINT "ApiKeyEnvironment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -49,14 +49,11 @@ model Webhook {
|
||||
source WebhookSource @default(user)
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
triggers PipelineTriggers[]
|
||||
surveyIds String[]
|
||||
secret String?
|
||||
|
||||
@@index([environmentId])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
/// Represents an attribute value associated with a contact.
|
||||
@@ -119,14 +116,11 @@ model ContactAttributeKey {
|
||||
dataType ContactAttributeDataType @default(string)
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
attributes ContactAttribute[]
|
||||
attributeFilters SurveyAttributeFilter[]
|
||||
|
||||
@@unique([key, environmentId])
|
||||
@@index([environmentId, createdAt])
|
||||
@@index([projectId, createdAt])
|
||||
}
|
||||
|
||||
/// Represents a person or user who can receive and respond to surveys.
|
||||
@@ -143,14 +137,11 @@ model Contact {
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
responses Response[]
|
||||
attributes ContactAttribute[]
|
||||
displays Display[]
|
||||
|
||||
@@index([environmentId])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
/// Stores a user's response to a survey, including their answers and metadata.
|
||||
@@ -213,11 +204,8 @@ model Tag {
|
||||
responses TagsOnResponses[]
|
||||
environmentId String
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
|
||||
@@unique([environmentId, name])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
/// Junction table linking tags to responses.
|
||||
@@ -362,8 +350,6 @@ model Survey {
|
||||
type SurveyType @default(app)
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
creator User? @relation(fields: [createdBy], references: [id])
|
||||
createdBy String?
|
||||
status SurveyStatus @default(draft)
|
||||
@@ -427,7 +413,6 @@ model Survey {
|
||||
|
||||
@@index([environmentId, updatedAt])
|
||||
@@index([segmentId])
|
||||
@@index([projectId, updatedAt])
|
||||
}
|
||||
|
||||
/// Represents a quota configuration for a survey.
|
||||
@@ -522,14 +507,11 @@ model ActionClass {
|
||||
noCodeConfig Json?
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
surveyTriggers SurveyTrigger[]
|
||||
|
||||
@@unique([key, environmentId])
|
||||
@@unique([name, environmentId])
|
||||
@@index([environmentId, createdAt])
|
||||
@@index([projectId, createdAt])
|
||||
}
|
||||
|
||||
enum EnvironmentType {
|
||||
@@ -558,12 +540,9 @@ model Integration {
|
||||
/// [IntegrationConfig]
|
||||
config Json
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
|
||||
@@unique([type, environmentId])
|
||||
@@index([environmentId])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
enum DataMigrationStatus {
|
||||
@@ -669,17 +648,6 @@ model Project {
|
||||
projectTeams ProjectTeam[]
|
||||
customHeadScripts String? // Custom HTML scripts for link surveys (self-hosted only)
|
||||
|
||||
// Direct resource relations (for environment deprecation migration)
|
||||
surveys Survey[]
|
||||
contacts Contact[]
|
||||
actionClasses ActionClass[]
|
||||
contactAttributeKeys ContactAttributeKey[]
|
||||
webhooks Webhook[]
|
||||
tags Tag[]
|
||||
segments Segment[]
|
||||
integrations Integration[]
|
||||
apiKeyEnvironments ApiKeyEnvironment[]
|
||||
|
||||
@@unique([organizationId, name])
|
||||
}
|
||||
|
||||
@@ -839,13 +807,10 @@ model ApiKeyEnvironment {
|
||||
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
permission ApiKeyPermission
|
||||
|
||||
@@unique([apiKeyId, environmentId])
|
||||
@@index([environmentId])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
enum IdentityProvider {
|
||||
@@ -888,6 +853,32 @@ model Account {
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// Stores active authentication sessions for revocable server-side login state.
|
||||
///
|
||||
/// @property sessionToken - Opaque token stored in the browser cookie
|
||||
/// @property user - The Formbricks user who owns this session
|
||||
/// @property expires - Hard expiry for the session
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
sessionToken String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
expires DateTime
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// Stores one-time verification tokens used by Auth.js adapter flows.
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
}
|
||||
|
||||
/// Represents a user in the Formbricks system.
|
||||
/// Central model for user authentication and profile management.
|
||||
///
|
||||
@@ -913,6 +904,7 @@ model User {
|
||||
identityProviderAccountId String?
|
||||
memberships Membership[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
groupId String?
|
||||
invitesCreated Invite[] @relation("inviteCreatedBy")
|
||||
invitesAccepted Invite[] @relation("inviteAcceptedBy")
|
||||
@@ -945,12 +937,9 @@ model Segment {
|
||||
filters Json @default("[]")
|
||||
environmentId String
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
projectId String?
|
||||
surveys Survey[]
|
||||
|
||||
@@unique([environmentId, title])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
/// Represents a supported language in the system.
|
||||
|
||||
@@ -10,7 +10,7 @@ export const ZApiKeyEnvironment = z.object({
|
||||
updatedAt: z.date(),
|
||||
apiKeyId: z.cuid2(),
|
||||
environmentId: z.cuid2(),
|
||||
projectId: z.cuid2().nullable(),
|
||||
projectId: z.cuid2(),
|
||||
projectName: z.string(),
|
||||
environmentType: z.enum(EnvironmentType),
|
||||
permission: ZApiKeyPermission,
|
||||
|
||||
@@ -54,7 +54,6 @@ export const ZContactAttributeKey = z.object({
|
||||
})
|
||||
.describe("The data type of the attribute (string, number, date)"),
|
||||
environmentId: z.cuid2().describe("The ID of the environment this attribute belongs to"),
|
||||
projectId: z.string().nullable().describe("The ID of the project this attribute belongs to"),
|
||||
}) satisfies z.ZodType<ContactAttributeKey>;
|
||||
|
||||
ZContactAttributeKey.meta({
|
||||
|
||||
@@ -17,7 +17,6 @@ export const ZContact = z.object({
|
||||
})
|
||||
.describe("When the contact was last updated"),
|
||||
environmentId: z.string().describe("The environment this contact belongs to"),
|
||||
projectId: z.string().nullable().describe("The project this contact belongs to"),
|
||||
}) satisfies z.ZodType<Contact>;
|
||||
|
||||
ZContact.meta({
|
||||
|
||||
@@ -72,7 +72,6 @@ const ZSurveyBase = z.object({
|
||||
pin: z.string().nullable().describe("The pin of the survey"),
|
||||
createdBy: z.string().nullable().describe("The user who created the survey"),
|
||||
environmentId: z.cuid2().describe("The environment ID of the survey"),
|
||||
projectId: z.string().nullable().describe("The project ID of the survey"),
|
||||
questions: z.array(ZSurveyQuestion).describe("The questions of the survey"),
|
||||
blocks: ZSurveyBlocks.prefault([]).describe("The blocks of the survey"),
|
||||
endings: z.array(ZSurveyEnding).prefault([]).describe("The endings of the survey"),
|
||||
|
||||
@@ -19,7 +19,6 @@ export const ZWebhook = z.object({
|
||||
url: z.url().describe("The URL of the webhook"),
|
||||
source: z.enum(["user", "zapier", "make", "n8n"]).describe("The source of the webhook"),
|
||||
environmentId: z.cuid2().describe("The ID of the environment"),
|
||||
projectId: z.string().nullable().describe("The ID of the project"),
|
||||
triggers: z
|
||||
.array(z.enum(["responseFinished", "responseCreated", "responseUpdated"]))
|
||||
.describe("The triggers of the webhook")
|
||||
|
||||
@@ -62,7 +62,6 @@ export const mockSurvey: TEnvironmentStateSurvey = {
|
||||
createdAt: new Date("2025-01-01T10:00:00Z"),
|
||||
updatedAt: new Date("2025-01-01T10:00:00Z"),
|
||||
environmentId: mockEnvironmentId,
|
||||
projectId: null,
|
||||
description: "Manual Trigger",
|
||||
noCodeConfig: {
|
||||
elementSelector: { cssSelector: ".btn", innerHtml: "Click me" },
|
||||
|
||||
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 },
|
||||
|
||||
@@ -135,7 +135,6 @@ export const ZActionClass = z.object({
|
||||
key: z.string().trim().min(1).nullable(),
|
||||
noCodeConfig: ZActionClassNoCodeConfig.nullable(),
|
||||
environmentId: z.string(),
|
||||
projectId: z.string().nullable(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@ export const ZContactAttributeKey = z.object({
|
||||
type: ZContactAttributeKeyType,
|
||||
dataType: ZContactAttributeDataType.prefault("string"),
|
||||
environmentId: z.string(),
|
||||
projectId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type TContactAttributeKey = z.infer<typeof ZContactAttributeKey>;
|
||||
|
||||
@@ -19,7 +19,6 @@ export type TIntegrationConfig = z.infer<typeof ZIntegrationConfig>;
|
||||
export const ZIntegrationBase = z.object({
|
||||
id: z.string(),
|
||||
environmentId: z.string(),
|
||||
projectId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const ZIntegration = ZIntegrationBase.extend({
|
||||
|
||||
@@ -3,7 +3,6 @@ import { z } from "zod";
|
||||
export const ZIntegrationBase = z.object({
|
||||
id: z.string(),
|
||||
environmentId: z.string(),
|
||||
projectId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const ZIntegrationBaseSurveyData = z.object({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user