mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 19:38:37 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9dcc5dd4b | |||
| d7fbb439e5 | |||
| 535c111860 |
+1
-1
@@ -70,7 +70,7 @@ SMTP_PASSWORD=smtpPassword
|
||||
# S3 STORAGE #
|
||||
##############
|
||||
|
||||
# S3 Storage is required for the file upload in serverless environments like Vercel
|
||||
# S3 Storage is required for the file upload in serverless environments
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_REGION=
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
apps/web/.env
|
||||
@@ -96,10 +96,7 @@ export const POST = withV1ApiWrapper({
|
||||
const agent = new UAParser(userAgent);
|
||||
|
||||
const country =
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
|
||||
|
||||
const responseInputData = responseInputValidation.data;
|
||||
|
||||
|
||||
@@ -35,10 +35,7 @@ type TValidatedResponseInputResult =
|
||||
| { response: Response };
|
||||
|
||||
const getCountry = (requestHeaders: Headers): string | undefined =>
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
|
||||
|
||||
const getUnexpectedPublicErrorResponse = (): Response =>
|
||||
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
|
||||
|
||||
@@ -152,7 +152,7 @@ describe("API Response Utilities", () => {
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const message = "Something went wrong";
|
||||
const customCache = "no-cache";
|
||||
const response = responses.internalServerErrorResponse(message, false, customCache);
|
||||
const response = responses.internalServerErrorResponse(message, false, {}, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
|
||||
@@ -217,6 +217,7 @@ const successResponse = (data: Object, cors: boolean = false, cache: string = "p
|
||||
const internalServerErrorResponse = (
|
||||
message: string,
|
||||
cors: boolean = false,
|
||||
details: ApiErrorResponse["details"] = {},
|
||||
cache: string = "private, no-store"
|
||||
) => {
|
||||
const headers = {
|
||||
@@ -228,7 +229,7 @@ const internalServerErrorResponse = (
|
||||
{
|
||||
code: "internal_server_error",
|
||||
message,
|
||||
details: {},
|
||||
details,
|
||||
} as ApiErrorResponse,
|
||||
{
|
||||
status: 500,
|
||||
|
||||
@@ -10,8 +10,7 @@ export const IS_DEVELOPMENT = env.NODE_ENV === "development";
|
||||
export const E2E_TESTING = env.E2E_TESTING === "1";
|
||||
|
||||
// URLs
|
||||
export const WEBAPP_URL =
|
||||
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
|
||||
export const WEBAPP_URL = env.WEBAPP_URL || "http://localhost:3000";
|
||||
|
||||
// encryption keys
|
||||
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
|
||||
|
||||
@@ -235,7 +235,6 @@ const parsedEnv = createEnv({
|
||||
TURNSTILE_SITE_KEY: z.string().optional(),
|
||||
RECAPTCHA_SITE_KEY: z.string().optional(),
|
||||
RECAPTCHA_SECRET_KEY: z.string().optional(),
|
||||
VERCEL_URL: z.string().optional(),
|
||||
WEBAPP_URL: z.url().optional(),
|
||||
UNSPLASH_ACCESS_KEY: z.string().optional(),
|
||||
|
||||
@@ -354,7 +353,6 @@ const parsedEnv = createEnv({
|
||||
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
|
||||
RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY,
|
||||
TERMS_URL: process.env.TERMS_URL,
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
WEBAPP_URL: process.env.WEBAPP_URL,
|
||||
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const envMock = {
|
||||
WEBAPP_URL: undefined as string | undefined,
|
||||
VERCEL_URL: undefined as string | undefined,
|
||||
PUBLIC_URL: undefined as string | undefined,
|
||||
};
|
||||
|
||||
@@ -19,7 +18,6 @@ const loadGetPublicDomain = async () => {
|
||||
describe("getPublicDomain", () => {
|
||||
beforeEach(() => {
|
||||
envMock.WEBAPP_URL = undefined;
|
||||
envMock.VERCEL_URL = undefined;
|
||||
envMock.PUBLIC_URL = undefined;
|
||||
});
|
||||
|
||||
@@ -31,16 +29,7 @@ describe("getPublicDomain", () => {
|
||||
expect(getPublicDomain()).toBe("https://app.formbricks.com");
|
||||
});
|
||||
|
||||
test("falls back to VERCEL_URL when WEBAPP_URL is empty", async () => {
|
||||
envMock.WEBAPP_URL = " ";
|
||||
envMock.VERCEL_URL = "preview.formbricks.com";
|
||||
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("https://preview.formbricks.com");
|
||||
});
|
||||
|
||||
test("falls back to localhost when WEBAPP_URL and VERCEL_URL are not set", async () => {
|
||||
test("falls back to localhost when WEBAPP_URL is not set", async () => {
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("http://localhost:3000");
|
||||
|
||||
@@ -2,17 +2,7 @@ import "server-only";
|
||||
import { env } from "./env";
|
||||
|
||||
const configuredWebappUrl = env.WEBAPP_URL?.trim() ?? "";
|
||||
const WEBAPP_URL = (() => {
|
||||
if (configuredWebappUrl !== "") {
|
||||
return configuredWebappUrl;
|
||||
}
|
||||
|
||||
if (env.VERCEL_URL) {
|
||||
return `https://${env.VERCEL_URL}`;
|
||||
}
|
||||
|
||||
return "http://localhost:3000";
|
||||
})();
|
||||
const WEBAPP_URL = configuredWebappUrl !== "" ? configuredWebappUrl : "http://localhost:3000";
|
||||
|
||||
/**
|
||||
* Returns the public domain URL
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
|
||||
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
|
||||
"field_placeholder": "{{field}}-Platzhalter",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filter",
|
||||
"finish": "Fertigstellen",
|
||||
"first_name": "Vorname",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "Failed to load organizations",
|
||||
"failed_to_load_workspaces": "Failed to load workspaces",
|
||||
"field_placeholder": "{{field}} Placeholder",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "First Name",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "Error al cargar organizaciones",
|
||||
"failed_to_load_workspaces": "Error al cargar los proyectos",
|
||||
"field_placeholder": "Marcador de posición de {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtro",
|
||||
"finish": "Finalizar",
|
||||
"first_name": "Nombre",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "Échec du chargement des organisations",
|
||||
"failed_to_load_workspaces": "Échec du chargement des projets",
|
||||
"field_placeholder": "Espace réservé {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtre",
|
||||
"finish": "Terminer",
|
||||
"first_name": "Prénom",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
|
||||
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
|
||||
"field_placeholder": "{{field}} helykitöltője",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Szűrő",
|
||||
"finish": "Befejezés",
|
||||
"first_name": "Keresztnév",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
||||
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
|
||||
"field_placeholder": "{{field}} プレースホルダー",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "フィルター",
|
||||
"finish": "完了",
|
||||
"first_name": "名",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "Laden van organisaties mislukt",
|
||||
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
|
||||
"field_placeholder": "Tijdelijke aanduiding voor {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "Voornaam",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
||||
"field_placeholder": "Espaço reservado de {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtro",
|
||||
"finish": "Terminar",
|
||||
"first_name": "Primeiro nome",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
||||
"field_placeholder": "Espaço reservado de {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtro",
|
||||
"finish": "Concluir",
|
||||
"first_name": "Primeiro nome",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
|
||||
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
|
||||
"field_placeholder": "Substituent {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtru",
|
||||
"finish": "Finalizează",
|
||||
"first_name": "Prenume",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "Не удалось загрузить организации",
|
||||
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
|
||||
"field_placeholder": "Заполнитель {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Фильтр",
|
||||
"finish": "Завершить",
|
||||
"first_name": "Имя",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
|
||||
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
|
||||
"field_placeholder": "Platshållare för {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filter",
|
||||
"finish": "Slutför",
|
||||
"first_name": "Förnamn",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "Organizasyonlar yüklenemedi",
|
||||
"failed_to_load_workspaces": "Çalışma alanları yüklenemedi",
|
||||
"field_placeholder": "{{field}} Yer Tutucu",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtre",
|
||||
"finish": "Bitir",
|
||||
"first_name": "Ad",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "加载组织失败",
|
||||
"failed_to_load_workspaces": "加载工作区失败",
|
||||
"field_placeholder": "{{field}} 占位符",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "筛选",
|
||||
"finish": "完成",
|
||||
"first_name": "名字",
|
||||
|
||||
@@ -241,6 +241,9 @@
|
||||
"failed_to_load_organizations": "無法載入組織",
|
||||
"failed_to_load_workspaces": "載入工作區失敗",
|
||||
"field_placeholder": "{{field}} 預設文字",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "篩選",
|
||||
"finish": "完成",
|
||||
"first_name": "名字",
|
||||
|
||||
@@ -6,7 +6,6 @@ import { InvalidInputError, UnknownError } from "@formbricks/types/errors";
|
||||
import { ZUser, ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_TURNSTILE_CONFIGURED,
|
||||
TURNSTILE_SECRET_KEY,
|
||||
@@ -46,6 +45,7 @@ const ZCreateUserAction = z.object({
|
||||
password: ZUserPassword,
|
||||
inviteToken: z.string().optional(),
|
||||
userLocale: ZUserLocale.optional(),
|
||||
emailVerificationDisabled: z.boolean().optional(),
|
||||
turnstileToken: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -53,6 +53,7 @@ const ZCreateUserAction = z.object({
|
||||
(token) => !IS_TURNSTILE_CONFIGURED || (IS_TURNSTILE_CONFIGURED && token),
|
||||
"CAPTCHA verification required"
|
||||
),
|
||||
isFormbricksCloud: z.boolean(),
|
||||
subscribeToSecurityUpdates: z.boolean().optional(),
|
||||
subscribeToProductUpdates: z.boolean().optional(),
|
||||
});
|
||||
@@ -201,7 +202,8 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
|
||||
async function handlePostUserCreation(
|
||||
ctx: ActionClientCtx,
|
||||
user: TCreatedUser,
|
||||
inviteToken: string | undefined
|
||||
inviteToken: string | undefined,
|
||||
emailVerificationDisabled: boolean | undefined
|
||||
): Promise<void> {
|
||||
if (inviteToken) {
|
||||
await handleInviteAcceptance(ctx, inviteToken, user);
|
||||
@@ -209,7 +211,7 @@ async function handlePostUserCreation(
|
||||
await handleOrganizationCreation(ctx, user);
|
||||
}
|
||||
|
||||
if (!EMAIL_VERIFICATION_DISABLED) {
|
||||
if (!emailVerificationDisabled) {
|
||||
let inviteCallbackUrl: string | undefined;
|
||||
|
||||
if (inviteToken) {
|
||||
@@ -241,11 +243,11 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti
|
||||
);
|
||||
|
||||
if (!userAlreadyExisted && user) {
|
||||
await handlePostUserCreation(ctx, user, parsedInput.inviteToken);
|
||||
await handlePostUserCreation(ctx, user, parsedInput.inviteToken, parsedInput.emailVerificationDisabled);
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: user.email,
|
||||
isFormbricksCloud: IS_FORMBRICKS_CLOUD,
|
||||
isFormbricksCloud: parsedInput.isFormbricksCloud,
|
||||
subscribeToSecurityUpdates: parsedInput.subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
|
||||
});
|
||||
|
||||
@@ -114,7 +114,9 @@ export const SignupForm = ({
|
||||
password: data.password,
|
||||
userLocale,
|
||||
inviteToken: inviteToken ?? "",
|
||||
emailVerificationDisabled,
|
||||
turnstileToken,
|
||||
isFormbricksCloud,
|
||||
subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -378,7 +378,7 @@ describe("License Core Logic", () => {
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -410,7 +410,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -444,7 +444,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -475,7 +475,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -506,7 +506,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -571,7 +571,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -627,7 +627,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -683,7 +683,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -722,7 +722,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -748,7 +748,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -899,7 +899,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -946,7 +946,7 @@ describe("License Core Logic", () => {
|
||||
vi.doMock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: undefined,
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
@@ -969,7 +969,7 @@ describe("License Core Logic", () => {
|
||||
env: {
|
||||
ENTERPRISE_LICENSE_KEY: testLicenseKey,
|
||||
ENVIRONMENT: "production",
|
||||
VERCEL_URL: "some.vercel.url",
|
||||
|
||||
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
||||
HTTPS_PROXY: undefined,
|
||||
HTTP_PROXY: undefined,
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@ import {
|
||||
updateOrganizationEmailLogoUrlAction,
|
||||
} from "@/modules/ee/whitelabel/email-customization/actions";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
|
||||
@@ -138,7 +139,7 @@ export const EmailCustomizationSettings = ({
|
||||
const { url, error } = await handleFileUpload(logoFile, environmentId, allowedFileExtensions);
|
||||
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
showFileUploadErrorToast(error, t);
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ import {
|
||||
updateOrganizationFaviconUrlAction,
|
||||
} from "@/modules/ee/whitelabel/favicon-customization/actions";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
@@ -58,7 +59,7 @@ export const FaviconCustomizationSettings = ({
|
||||
try {
|
||||
const uploadResult = await handleFileUpload(file, environmentId, allowedFileExtensions);
|
||||
if (uploadResult.error) {
|
||||
toast.error(uploadResult.error);
|
||||
showFileUploadErrorToast(uploadResult.error, t);
|
||||
return;
|
||||
}
|
||||
setFaviconUrl(uploadResult.url);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateProjectAction } from "@/modules/projects/settings/actions";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -39,7 +40,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly, isStorageConfigur
|
||||
try {
|
||||
const uploadResult = await handleFileUpload(file, environmentId);
|
||||
if (uploadResult.error) {
|
||||
toast.error(uploadResult.error);
|
||||
showFileUploadErrorToast(uploadResult.error, t);
|
||||
return;
|
||||
}
|
||||
setLogoUrl(uploadResult.url);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { type TFunction } from "i18next";
|
||||
import toast from "react-hot-toast";
|
||||
import { FileUploadError } from "@/modules/storage/file-upload";
|
||||
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
|
||||
|
||||
export const getFileUploadErrorMessage = (error: FileUploadError, t: TFunction): string => {
|
||||
switch (error) {
|
||||
case FileUploadError.NO_FILE:
|
||||
return t("common.no_files_uploaded");
|
||||
case FileUploadError.INVALID_FILE_TYPE:
|
||||
return t("common.invalid_file_type");
|
||||
case FileUploadError.FILE_SIZE_EXCEEDED:
|
||||
return t("common.file_size_must_be_less_than_5_mb");
|
||||
case FileUploadError.INVALID_FILE_NAME:
|
||||
return t("common.invalid_file_name");
|
||||
case FileUploadError.STORAGE_NOT_CONFIGURED:
|
||||
return t("common.storage_not_configured");
|
||||
case FileUploadError.STORAGE_UPLOAD_FAILED:
|
||||
return t("common.file_upload_service_unavailable");
|
||||
case FileUploadError.UPLOAD_FAILED:
|
||||
default:
|
||||
return t("common.upload_failed");
|
||||
}
|
||||
};
|
||||
|
||||
export const showFileUploadErrorToast = (error: FileUploadError, t: TFunction): void => {
|
||||
if (error === FileUploadError.STORAGE_NOT_CONFIGURED) {
|
||||
showStorageNotConfiguredToast("notConfigured");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error === FileUploadError.STORAGE_UPLOAD_FAILED) {
|
||||
showStorageNotConfiguredToast("uploadUnavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(getFileUploadErrorMessage(error, t));
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { STORAGE_ERROR_CODES } from "@formbricks/types/storage";
|
||||
import * as fileUploadModule from "./file-upload";
|
||||
|
||||
// Mock global fetch
|
||||
@@ -67,7 +68,41 @@ describe("fileUpload", () => {
|
||||
});
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe("Upload failed. Please try again.");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.UPLOAD_FAILED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should return STORAGE_NOT_CONFIGURED when signing API returns a storage configuration error", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({
|
||||
code: "internal_server_error",
|
||||
message: "File storage is not configured correctly. Please check your file upload settings.",
|
||||
details: { storage_error_code: STORAGE_ERROR_CODES.S3_CREDENTIALS_ERROR },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.STORAGE_NOT_CONFIGURED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should return INVALID_FILE_NAME when signing API rejects the file name", async () => {
|
||||
const file = createMockFile("----.jpg", "image/jpeg", 1000);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ details: { fileName: "Invalid file name" } }),
|
||||
});
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.INVALID_FILE_NAME);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
@@ -129,7 +164,7 @@ describe("fileUpload", () => {
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe("Upload failed. Please try again.");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.UPLOAD_FAILED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
@@ -161,7 +196,34 @@ describe("fileUpload", () => {
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe("Upload failed. Please try again.");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.STORAGE_UPLOAD_FAILED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should return STORAGE_UPLOAD_FAILED when storage upload request throws", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
signedUrl: "https://s3.example.com/upload",
|
||||
fileUrl: "/storage/test-env/public/file.jpg",
|
||||
presignedFields: {
|
||||
key: "value",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
setTimeout(() => {
|
||||
mockFileReader.onload();
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.STORAGE_UPLOAD_FAILED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
import { STORAGE_CONFIGURATION_ERROR_CODES, type TStorageApiErrorDetails } from "@formbricks/types/storage";
|
||||
|
||||
export enum FileUploadError {
|
||||
NO_FILE = "No file provided or invalid file type. Expected a File or Blob.",
|
||||
INVALID_FILE_TYPE = "Please upload an image file.",
|
||||
FILE_SIZE_EXCEEDED = "File size must be less than 5 MB.",
|
||||
UPLOAD_FAILED = "Upload failed. Please try again.",
|
||||
INVALID_FILE_NAME = "Invalid file name. Please rename your file and try again.",
|
||||
NO_FILE = "no_file",
|
||||
INVALID_FILE_TYPE = "invalid_file_type",
|
||||
FILE_SIZE_EXCEEDED = "file_size_exceeded",
|
||||
UPLOAD_FAILED = "upload_failed",
|
||||
INVALID_FILE_NAME = "invalid_file_name",
|
||||
STORAGE_NOT_CONFIGURED = "storage_not_configured",
|
||||
STORAGE_UPLOAD_FAILED = "storage_upload_failed",
|
||||
}
|
||||
|
||||
type UploadApiErrorResponse = {
|
||||
details?: TStorageApiErrorDetails;
|
||||
};
|
||||
|
||||
const parseUploadApiError = async (response: Response): Promise<UploadApiErrorResponse | undefined> => {
|
||||
try {
|
||||
return (await response.json()) as UploadApiErrorResponse;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getFileUploadErrorFromResponse = async (response: Response): Promise<FileUploadError> => {
|
||||
const json = await parseUploadApiError(response);
|
||||
|
||||
if (response.status === 400 && json?.details?.fileName) {
|
||||
return FileUploadError.INVALID_FILE_NAME;
|
||||
}
|
||||
|
||||
if (
|
||||
response.status >= 500 &&
|
||||
json?.details?.storage_error_code &&
|
||||
STORAGE_CONFIGURATION_ERROR_CODES.has(json.details.storage_error_code)
|
||||
) {
|
||||
return FileUploadError.STORAGE_NOT_CONFIGURED;
|
||||
}
|
||||
|
||||
return FileUploadError.UPLOAD_FAILED;
|
||||
};
|
||||
|
||||
export const toBase64 = (file: File) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -61,18 +95,8 @@ export const handleFileUpload = async (
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 400) {
|
||||
const json = (await response.json()) as { details?: { fileName?: string } };
|
||||
if (json.details?.fileName) {
|
||||
return {
|
||||
error: FileUploadError.INVALID_FILE_NAME,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: FileUploadError.UPLOAD_FAILED,
|
||||
error: await getFileUploadErrorFromResponse(response),
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
@@ -107,14 +131,24 @@ export const handleFileUpload = async (
|
||||
};
|
||||
}
|
||||
|
||||
const uploadResponse = await fetch(signedUrl, {
|
||||
method: "POST",
|
||||
body: formDataForS3,
|
||||
});
|
||||
let uploadResponse: Response;
|
||||
|
||||
try {
|
||||
uploadResponse = await fetch(signedUrl, {
|
||||
method: "POST",
|
||||
body: formDataForS3,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error in uploading file: ", err);
|
||||
return {
|
||||
error: FileUploadError.STORAGE_UPLOAD_FAILED,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
return {
|
||||
error: FileUploadError.UPLOAD_FAILED,
|
||||
error: FileUploadError.STORAGE_UPLOAD_FAILED,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +98,9 @@ describe("storage utils", () => {
|
||||
);
|
||||
const spyISE = vi
|
||||
.spyOn(responseMod.responses, "internalServerErrorResponse")
|
||||
.mockImplementation((_msg: string, _public?: boolean) => new Response(null, { status: 500 }));
|
||||
.mockImplementation((msg: string, _public?: boolean, details = {}) =>
|
||||
Response.json({ code: "internal_server_error", message: msg, details }, { status: 500 })
|
||||
);
|
||||
|
||||
const { getErrorResponseFromStorageError } = await import("@/modules/storage/utils");
|
||||
|
||||
@@ -120,8 +122,16 @@ describe("storage utils", () => {
|
||||
// S3 related and Unknown -> 500
|
||||
const r500a = getErrorResponseFromStorageError({ code: StorageErrorCode.S3ClientError });
|
||||
expect(r500a.status).toBe(500);
|
||||
await expect(r500a.json()).resolves.toMatchObject({
|
||||
message: "File storage is not configured correctly. Please check your file upload settings.",
|
||||
details: { storage_error_code: StorageErrorCode.S3ClientError },
|
||||
});
|
||||
const r500b = getErrorResponseFromStorageError({ code: StorageErrorCode.S3CredentialsError });
|
||||
expect(r500b.status).toBe(500);
|
||||
await expect(r500b.json()).resolves.toMatchObject({
|
||||
message: "File storage is not configured correctly. Please check your file upload settings.",
|
||||
details: { storage_error_code: StorageErrorCode.S3CredentialsError },
|
||||
});
|
||||
const r500c = getErrorResponseFromStorageError({ code: StorageErrorCode.Unknown });
|
||||
expect(r500c.status).toBe(500);
|
||||
|
||||
|
||||
@@ -121,9 +121,13 @@ export const getErrorResponseFromStorageError = (
|
||||
case StorageErrorCode.InvalidInput:
|
||||
return responses.badRequestResponse("Invalid input", details, true);
|
||||
case StorageErrorCode.S3ClientError:
|
||||
return responses.internalServerErrorResponse("Internal server error", true);
|
||||
case StorageErrorCode.S3CredentialsError:
|
||||
return responses.internalServerErrorResponse("Internal server error", true);
|
||||
return responses.internalServerErrorResponse(
|
||||
"File storage is not configured correctly. Please check your file upload settings.",
|
||||
true,
|
||||
{ storage_error_code: error.code },
|
||||
"private, no-store"
|
||||
);
|
||||
case StorageErrorCode.Unknown:
|
||||
return responses.internalServerErrorResponse("Internal server error", true);
|
||||
default: {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TProjectStyling } from "@formbricks/types/project";
|
||||
import { TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ColorPicker } from "@/modules/ui/components/color-picker";
|
||||
@@ -81,7 +82,7 @@ export const LogoSettingsCard = ({
|
||||
try {
|
||||
const uploadResult = await handleFileUpload(file, environmentId);
|
||||
if (uploadResult.error) {
|
||||
toast.error(t("common.upload_failed"));
|
||||
showFileUploadErrorToast(uploadResult.error, t);
|
||||
return;
|
||||
}
|
||||
setLogoUrl(uploadResult.url);
|
||||
|
||||
@@ -7,7 +7,8 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { FileUploadError, handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
|
||||
@@ -93,10 +94,10 @@ export const FileInput = ({
|
||||
|
||||
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
|
||||
const firstError = uploadedFiles.find((f) => f.error)?.error;
|
||||
if (firstError === FileUploadError.INVALID_FILE_NAME) {
|
||||
toast.error(t("common.invalid_file_name"));
|
||||
} else if (uploadedFiles.length === 0) {
|
||||
if (uploadedFiles.length === 0) {
|
||||
toast.error(t("common.no_files_uploaded"));
|
||||
} else if (firstError) {
|
||||
showFileUploadErrorToast(firstError, t);
|
||||
} else {
|
||||
toast.error(t("common.some_files_failed_to_upload"));
|
||||
}
|
||||
@@ -167,10 +168,10 @@ export const FileInput = ({
|
||||
|
||||
if (uploadedFiles.length < allowedFiles.length || uploadedFiles.some((file) => file.error)) {
|
||||
const firstError = uploadedFiles.find((f) => f.error)?.error;
|
||||
if (firstError === FileUploadError.INVALID_FILE_NAME) {
|
||||
toast.error(t("common.invalid_file_name"));
|
||||
} else if (uploadedFiles.length === 0) {
|
||||
if (uploadedFiles.length === 0) {
|
||||
toast.error(t("common.no_files_uploaded"));
|
||||
} else if (firstError) {
|
||||
showFileUploadErrorToast(firstError, t);
|
||||
} else {
|
||||
toast.error(t("common.some_files_failed_to_upload"));
|
||||
}
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
"use client";
|
||||
|
||||
export const StorageNotConfiguredToast = () => {
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface StorageNotConfiguredToastProps {
|
||||
variant?: "notConfigured" | "uploadUnavailable";
|
||||
}
|
||||
|
||||
export const StorageNotConfiguredToast = ({ variant = "notConfigured" }: StorageNotConfiguredToastProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex w-fit !max-w-md items-center justify-center gap-2">
|
||||
<span className="text-slate-900">File storage not set up</span>
|
||||
<span className="text-slate-900">
|
||||
{variant === "uploadUnavailable"
|
||||
? t("common.file_upload_service_unavailable")
|
||||
: t("common.file_storage_not_set_up")}
|
||||
</span>
|
||||
<a
|
||||
className="text-slate-900 underline"
|
||||
href="https://formbricks.com/docs/self-hosting/configuration/file-uploads"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Learn more
|
||||
{t("common.learn_more")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import toast from "react-hot-toast";
|
||||
import { StorageNotConfiguredToast } from "../index";
|
||||
|
||||
export const showStorageNotConfiguredToast = () => {
|
||||
return toast.error(() => <StorageNotConfiguredToast />, {
|
||||
export const showStorageNotConfiguredToast = (
|
||||
variant: "notConfigured" | "uploadUnavailable" = "notConfigured"
|
||||
) => {
|
||||
return toast.error(() => <StorageNotConfiguredToast variant={variant} />, {
|
||||
id: "storage-not-configured-toast",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
||||
// The config you add here will be used whenever one of the edge features is loaded.
|
||||
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
// Note that this config is also required when running locally.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"functions": {
|
||||
"app/**/*.ts": {
|
||||
"maxDuration": 10,
|
||||
"memory": 512
|
||||
},
|
||||
"app/api/cron/**/*.ts": {
|
||||
"maxDuration": 180,
|
||||
"memory": 512
|
||||
},
|
||||
"app/api/v1/client/**/*.ts": {
|
||||
"maxDuration": 10,
|
||||
"memory": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ x-environment: &environment
|
||||
|
||||
################################################### OPTIONAL (STORAGE) ###################################################
|
||||
|
||||
# Set S3 Storage configuration (required for the file upload in serverless environments like Vercel)
|
||||
# Set S3 Storage configuration (required for the file upload in serverless environments)
|
||||
# S3_ACCESS_KEY:
|
||||
# S3_SECRET_KEY:
|
||||
# S3_REGION:
|
||||
|
||||
@@ -6,7 +6,7 @@ icon: code
|
||||
|
||||
## TypeScript
|
||||
|
||||
Our codebase follows the Vercel Engineering Style Guide conventions.
|
||||
Our codebase uses the `@vercel/style-guide` ESLint configurations for consistent code quality.
|
||||
|
||||
### ESLint Configuration
|
||||
|
||||
|
||||
@@ -1323,7 +1323,7 @@ Please note that their values and the logic remains exactly the same. Only the p
|
||||
|
||||
### Deprecated Environment Variables
|
||||
|
||||
- **`NEXT_PUBLIC_VERCEL_URL`**: Was used as Vercel URL (used instead of `WEBAPP_URL)`, but from v1.1, you can just set the `WEBAPP_URL` environment variable to your Vercel URL.
|
||||
- **`NEXT_PUBLIC_VERCEL_URL`**: Was used as deployment URL fallback (used instead of `WEBAPP_URL`), but from v1.1, you can just set the `WEBAPP_URL` environment variable.
|
||||
|
||||
- **`RAILWAY_STATIC_URL`**: Was used as Railway Static URL (used instead of `WEBAPP_URL`), but from v1.1, you can just set the `WEBAPP_URL` environment variable.
|
||||
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "الملفات التالية تم تحميلها بالفعل: {duplicateNames}. لا يُسمح بالملفات المكررة.",
|
||||
"file_size_exceeded": "الملف (الملفات) التالية تتجاوز الحجم الأقصى البالغ {maxSizeInMB} ميجابايت وتم إزالتها: {fileNames}",
|
||||
"file_size_exceeded_alert": "يجب أن يكون حجم الملف أقل من {maxSizeInMB} ميجابايت",
|
||||
"invalid_file_name": "يحتوي اسم الملف على أحرف غير صالحة. يُرجى إعادة تسمية الملف والمحاولة مرة أخرى.",
|
||||
"no_valid_file_types_selected": "لم يتم اختيار أنواع ملفات صالحة. يرجى اختيار نوع ملف صالح.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "يمكن تحميل ملف واحد فقط في المرة الواحدة.",
|
||||
"placeholder_text": "انقر أو اسحب لرفع الملفات",
|
||||
"upload_failed": "فشل التحميل! يرجى المحاولة مرة أخرى.",
|
||||
"upload_service_unavailable": "خدمة تحميل الملفات غير متاحة. يُرجى المحاولة مرة أخرى لاحقًا أو التواصل مع مالك الاستبيان.",
|
||||
"uploading": "جارٍ الرفع...",
|
||||
"you_can_only_upload_a_maximum_of_files": "يمكنك تحميل {FILE_LIMIT} ملفات كحد أقصى."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "Følgende filer er allerede uploadet: {duplicateNames}. Duplikatfiler er ikke tilladt.",
|
||||
"file_size_exceeded": "Følgende fil(er) overstiger den maksimale størrelse på {maxSizeInMB} MB og blev fjernet: {fileNames}",
|
||||
"file_size_exceeded_alert": "Filen skal være mindre end {maxSizeInMB} MB",
|
||||
"invalid_file_name": "Filnavnet indeholder ugyldige tegn. Omdøb filen, og prøv igen.",
|
||||
"no_valid_file_types_selected": "Ingen gyldige filtyper valgt. Vælg venligst en gyldig filtype.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Du kan kun uploade én fil ad gangen.",
|
||||
"placeholder_text": "Klik eller træk for at uploade filer",
|
||||
"upload_failed": "Upload mislykkedes! Prøv venligst igen.",
|
||||
"upload_service_unavailable": "Filuploadtjenesten er ikke tilgængelig. Prøv igen senere, eller kontakt undersøgelsens ejer.",
|
||||
"uploading": "Uploader...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Du kan maksimalt uploade {FILE_LIMIT} filer."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "Die folgenden Dateien sind bereits hochgeladen: {duplicateNames}. Doppelte Dateien sind nicht erlaubt.",
|
||||
"file_size_exceeded": "Die folgenden Dateien überschreiten die maximale Größe von {maxSizeInMB} MB und wurden entfernt: {fileNames}",
|
||||
"file_size_exceeded_alert": "Die Datei sollte kleiner als {maxSizeInMB} MB sein",
|
||||
"invalid_file_name": "Der Dateiname enthält ungültige Zeichen. Bitte benenne die Datei um und versuche es erneut.",
|
||||
"no_valid_file_types_selected": "Keine gültigen Dateitypen ausgewählt. Bitte wählen Sie einen gültigen Dateityp.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Es kann nur eine Datei gleichzeitig hochgeladen werden.",
|
||||
"placeholder_text": "Klicke oder ziehe Dateien hierher zum Hochladen",
|
||||
"upload_failed": "Upload fehlgeschlagen! Bitte versuchen Sie es erneut.",
|
||||
"upload_service_unavailable": "Der Datei-Upload-Dienst ist nicht verfügbar. Bitte versuche es später erneut oder kontaktiere den Umfragebesitzer.",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Sie können maximal {FILE_LIMIT} Dateien hochladen."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "The following files are already uploaded: {duplicateNames}. Duplicate files are not allowed.",
|
||||
"file_size_exceeded": "The following file(s) exceed the maximum size of {maxSizeInMB} MB and were removed: {fileNames}",
|
||||
"file_size_exceeded_alert": "File should be less than {maxSizeInMB} MB",
|
||||
"invalid_file_name": "The file name contains invalid characters. Please rename the file and try again.",
|
||||
"no_valid_file_types_selected": "No valid file types selected. Please select a valid file type.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Only one file can be uploaded at a time.",
|
||||
"placeholder_text": "Click or drag to upload files",
|
||||
"upload_failed": "Upload failed! Please try again.",
|
||||
"upload_service_unavailable": "File upload service is unavailable. Please try again later or contact the survey owner.",
|
||||
"uploading": "Uploading...",
|
||||
"you_can_only_upload_a_maximum_of_files": "You can only upload a maximum of {FILE_LIMIT} files."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "Los siguientes archivos ya están subidos: {duplicateNames}. No se permiten archivos duplicados.",
|
||||
"file_size_exceeded": "Los siguientes archivos exceden el tamaño máximo de {maxSizeInMB} MB y fueron eliminados: {fileNames}",
|
||||
"file_size_exceeded_alert": "El archivo debe ser menor de {maxSizeInMB} MB",
|
||||
"invalid_file_name": "El nombre del archivo contiene caracteres no válidos. Cambia el nombre del archivo e inténtalo de nuevo.",
|
||||
"no_valid_file_types_selected": "No se han seleccionado tipos de archivo válidos. Por favor, selecciona un tipo de archivo válido.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Solo se puede subir un archivo a la vez.",
|
||||
"placeholder_text": "Haz clic o arrastra para subir archivos",
|
||||
"upload_failed": "¡Subida fallida! Por favor, inténtalo de nuevo.",
|
||||
"upload_service_unavailable": "El servicio de carga de archivos no está disponible. Inténtalo de nuevo más tarde o contacta con el propietario de la encuesta.",
|
||||
"uploading": "Subiendo...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Solo puedes subir un máximo de {FILE_LIMIT} archivos."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"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",
|
||||
"invalid_file_name": "Failinimi sisaldab sobimatuid märke. Nimeta fail ümber ja proovi uuesti.",
|
||||
"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.",
|
||||
"upload_service_unavailable": "Failide üleslaadimise teenus pole saadaval. Proovi hiljem uuesti või võta ühendust küsitluse omanikuga.",
|
||||
"uploading": "Üleslaadimine...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Saad üles laadida maksimaalselt {FILE_LIMIT} faili."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "Les fichiers suivants sont déjà téléchargés : {duplicateNames}. Les fichiers en double ne sont pas autorisés.",
|
||||
"file_size_exceeded": "Les fichiers suivants dépassent la taille maximale de {maxSizeInMB} Mo et ont été supprimés : {fileNames}",
|
||||
"file_size_exceeded_alert": "Le fichier doit être inférieur à {maxSizeInMB} Mo",
|
||||
"invalid_file_name": "Le nom du fichier contient des caractères non valides. Renommez le fichier et réessayez.",
|
||||
"no_valid_file_types_selected": "Aucun type de fichier valide sélectionné. Veuillez sélectionner un type de fichier valide.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Un seul fichier peut être téléchargé à la fois.",
|
||||
"placeholder_text": "Cliquez ou glissez pour télécharger des fichiers",
|
||||
"upload_failed": "Échec du téléchargement ! Veuillez réessayer.",
|
||||
"upload_service_unavailable": "Le service de téléversement de fichiers est indisponible. Réessayez plus tard ou contactez le propriétaire du sondage.",
|
||||
"uploading": "Téléchargement en cours...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Vous ne pouvez télécharger qu'un maximum de {FILE_LIMIT} fichiers."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "निम्नलिखित फ़ाइलें पहले से ही अपलोड की गई हैं: {duplicateNames}। डुप्लिकेट फ़ाइलों की अनुमति नहीं है।",
|
||||
"file_size_exceeded": "निम्नलिखित फ़ाइल(ें) अधिकतम आकार {maxSizeInMB} MB से अधिक हैं और हटा दी गई हैं: {fileNames}",
|
||||
"file_size_exceeded_alert": "फ़ाइल {maxSizeInMB} MB से कम होनी चाहिए",
|
||||
"invalid_file_name": "फ़ाइल नाम में अमान्य वर्ण हैं। कृपया फ़ाइल का नाम बदलें और फिर से प्रयास करें।",
|
||||
"no_valid_file_types_selected": "कोई मान्य फ़ाइल प्रकार नहीं चुना गया है। कृपया एक मान्य फ़ाइल प्रकार चुनें।",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "एक समय में केवल एक फ़ाइल अपलोड की जा सकती है।",
|
||||
"placeholder_text": "फ़ाइलें अपलोड करने के लिए क्लिक करें या ड्रैग करें",
|
||||
"upload_failed": "अपलोड विफल! कृपया पुनः प्रयास करें।",
|
||||
"upload_service_unavailable": "फ़ाइल अपलोड सेवा उपलब्ध नहीं है। कृपया बाद में फिर से प्रयास करें या सर्वेक्षण के मालिक से संपर्क करें।",
|
||||
"uploading": "अपलोड हो रहा है...",
|
||||
"you_can_only_upload_a_maximum_of_files": "आप अधिकतम {FILE_LIMIT} फ़ाइलें ही अपलोड कर सकते हैं।"
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "A következő fájlok már fel lettek töltve: {duplicateNames}. Kettőzött fájlok nem engedélyezettek.",
|
||||
"file_size_exceeded": "A következő fájlok túllépik a legnagyobb, {maxSizeInMB} MB-os méretet, ezért eltávolításra kerültek: {fileNames}",
|
||||
"file_size_exceeded_alert": "A fájlnak kisebbnek kell lennie mint {maxSizeInMB} MB",
|
||||
"invalid_file_name": "A fájlnév érvénytelen karaktereket tartalmaz. Nevezd át a fájlt, majd próbáld újra.",
|
||||
"no_valid_file_types_selected": "Nincs érvényes fájltípus kiválasztva. Válasszon egy érvényes fájltípust.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Egyszerre csak egy fájl tölthető fel.",
|
||||
"placeholder_text": "Kattintson vagy húzza ide a fájlok feltöltéséhez",
|
||||
"upload_failed": "A feltöltés nem sikerült! Próbálja meg újra.",
|
||||
"upload_service_unavailable": "A fájlfeltöltési szolgáltatás nem érhető el. Próbáld újra később, vagy vedd fel a kapcsolatot a felmérés tulajdonosával.",
|
||||
"uploading": "Feltöltés…",
|
||||
"you_can_only_upload_a_maximum_of_files": "Legfeljebb csak {FILE_LIMIT} fájlt tölthet fel."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "I seguenti file sono già caricati: {duplicateNames}. I file duplicati non sono consentiti.",
|
||||
"file_size_exceeded": "I seguenti file superano la dimensione massima di {maxSizeInMB} MB e sono stati rimossi: {fileNames}",
|
||||
"file_size_exceeded_alert": "Il file deve essere inferiore a {maxSizeInMB} MB",
|
||||
"invalid_file_name": "Il nome del file contiene caratteri non validi. Rinomina il file e riprova.",
|
||||
"no_valid_file_types_selected": "Nessun tipo di file valido selezionato. Seleziona un tipo di file valido.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "È possibile caricare solo un file alla volta.",
|
||||
"placeholder_text": "Clicca o trascina per caricare i file",
|
||||
"upload_failed": "Caricamento fallito! Riprova.",
|
||||
"upload_service_unavailable": "Il servizio di caricamento file non è disponibile. Riprova più tardi o contatta il proprietario del sondaggio. (da verificare)",
|
||||
"uploading": "Caricamento in corso...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Puoi caricare un massimo di {FILE_LIMIT} file."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "以下のファイルはすでにアップロードされています:{duplicateNames}。重複ファイルは許可されていません。",
|
||||
"file_size_exceeded": "以下のファイルは最大サイズ{maxSizeInMB}MBを超えているため削除されました:{fileNames}",
|
||||
"file_size_exceeded_alert": "ファイルは{maxSizeInMB}MB未満である必要があります",
|
||||
"invalid_file_name": "ファイル名に無効な文字が含まれています。ファイル名を変更してもう一度お試しください。",
|
||||
"no_valid_file_types_selected": "有効なファイルタイプが選択されていません。有効なファイルタイプを選択してください。",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "一度にアップロードできるファイルは1つだけです。",
|
||||
"placeholder_text": "クリックまたはドラッグしてファイルをアップロード",
|
||||
"upload_failed": "アップロードに失敗しました!もう一度お試しください。",
|
||||
"upload_service_unavailable": "ファイルアップロードサービスは利用できません。後でもう一度試すか、アンケートの所有者に連絡してください。",
|
||||
"uploading": "アップロード中...",
|
||||
"you_can_only_upload_a_maximum_of_files": "アップロードできるファイルは最大{FILE_LIMIT}個までです。"
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "De volgende bestanden zijn al geüpload: {duplicateNames}. Dubbele bestanden zijn niet toegestaan.",
|
||||
"file_size_exceeded": "De volgende bestanden overschrijden de maximale grootte van {maxSizeInMB} MB en zijn verwijderd: {fileNames}",
|
||||
"file_size_exceeded_alert": "Het bestand moet kleiner zijn dan {maxSizeInMB} MB",
|
||||
"invalid_file_name": "De bestandsnaam bevat ongeldige tekens. Wijzig de bestandsnaam en probeer het opnieuw.",
|
||||
"no_valid_file_types_selected": "Geen geldige bestandstypen geselecteerd. Selecteer een geldig bestandstype.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Er kan slechts één bestand tegelijk worden geüpload.",
|
||||
"placeholder_text": "Klik of sleep bestanden om te uploaden",
|
||||
"upload_failed": "Uploaden mislukt! Probeer het opnieuw.",
|
||||
"upload_service_unavailable": "De service voor bestandsuploads is niet beschikbaar. Probeer het later opnieuw of neem contact op met de eigenaar van de enquête.",
|
||||
"uploading": "Uploaden...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Je kunt maximaal {FILE_LIMIT} bestanden uploaden."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "Os seguintes arquivos já foram carregados: {duplicateNames}. Arquivos duplicados não são permitidos.",
|
||||
"file_size_exceeded": "Os seguintes arquivos excedem o tamanho máximo de {maxSizeInMB} MB e foram removidos: {fileNames}",
|
||||
"file_size_exceeded_alert": "O arquivo deve ter menos de {maxSizeInMB} MB",
|
||||
"invalid_file_name": "O nome do ficheiro contém caracteres inválidos. Altere o nome do ficheiro e tente novamente.",
|
||||
"no_valid_file_types_selected": "Nenhum tipo de arquivo válido selecionado. Por favor, selecione um tipo de arquivo válido.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Apenas um arquivo pode ser carregado de cada vez.",
|
||||
"placeholder_text": "Clique ou arraste para enviar ficheiros",
|
||||
"upload_failed": "Falha no carregamento! Por favor, tente novamente.",
|
||||
"upload_service_unavailable": "O serviço de carregamento de ficheiros está indisponível. Tente novamente mais tarde ou contacte o proprietário do inquérito.",
|
||||
"uploading": "A enviar...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Você só pode carregar um máximo de {FILE_LIMIT} arquivos."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "Următoarele fișiere sunt deja încărcate: {duplicateNames}. Fișierele duplicate nu sunt permise.",
|
||||
"file_size_exceeded": "Următoarele fișiere depășesc dimensiunea maximă de {maxSizeInMB} MB și au fost eliminate: {fileNames}",
|
||||
"file_size_exceeded_alert": "Fișierul trebuie să fie mai mic de {maxSizeInMB} MB",
|
||||
"invalid_file_name": "Numele fișierului conține caractere nevalide. Redenumește fișierul și încearcă din nou.",
|
||||
"no_valid_file_types_selected": "Nu au fost selectate tipuri de fișiere valide. Te rugăm să selectezi un tip de fișier valid.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Poți încărca doar un singur fișier odată.",
|
||||
"placeholder_text": "Apasă sau trage pentru a încărca fișiere",
|
||||
"upload_failed": "Încărcarea a eșuat! Te rugăm să încerci din nou.",
|
||||
"upload_service_unavailable": "Serviciul de încărcare a fișierelor nu este disponibil. Încearcă din nou mai târziu sau contactează proprietarul sondajului.",
|
||||
"uploading": "Se încarcă...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Poți încărca un număr maxim de {FILE_LIMIT} fișiere."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "Следующие файлы уже загружены: {duplicateNames}. Дублирующие файлы не допускаются.",
|
||||
"file_size_exceeded": "Следующие файлы превышают максимальный размер {maxSizeInMB} МБ и были удалены: {fileNames}",
|
||||
"file_size_exceeded_alert": "Файл должен быть меньше {maxSizeInMB} МБ",
|
||||
"invalid_file_name": "Имя файла содержит недопустимые символы. Переименуйте файл и попробуйте снова.",
|
||||
"no_valid_file_types_selected": "Не выбраны допустимые типы файлов. Пожалуйста, выберите допустимый тип файла.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Можно загрузить только один файл за раз.",
|
||||
"placeholder_text": "Нажмите или перетащите файлы для загрузки",
|
||||
"upload_failed": "Ошибка загрузки! Пожалуйста, попробуйте снова.",
|
||||
"upload_service_unavailable": "Сервис загрузки файлов недоступен. Повторите попытку позже или свяжитесь с владельцем опроса.",
|
||||
"uploading": "Загрузка...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Вы можете загрузить максимум {FILE_LIMIT} файлов."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "Följande filer är redan uppladdade: {duplicateNames}. Dubbletter av filer är inte tillåtna.",
|
||||
"file_size_exceeded": "Följande filer överstiger maxstorleken på {maxSizeInMB} MB och togs bort: {fileNames}",
|
||||
"file_size_exceeded_alert": "Filen måste vara mindre än {maxSizeInMB} MB",
|
||||
"invalid_file_name": "Filnamnet innehåller ogiltiga tecken. Byt namn på filen och försök igen.",
|
||||
"no_valid_file_types_selected": "Inga giltiga filtyper valda. Vänligen välj en giltig filtyp.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Endast en fil kan laddas upp åt gången.",
|
||||
"placeholder_text": "Klicka eller dra för att ladda upp filer",
|
||||
"upload_failed": "Uppladdning misslyckades! Försök igen.",
|
||||
"upload_service_unavailable": "Filuppladdningstjänsten är inte tillgänglig. Försök igen senare eller kontakta enkätens ägare.",
|
||||
"uploading": "Laddar upp...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Du kan ladda upp maximalt {FILE_LIMIT} filer."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "Şu dosyalar zaten yüklendi: {duplicateNames}. Yinelenen dosyalara izin verilmez.",
|
||||
"file_size_exceeded": "Şu dosya(lar) maksimum {maxSizeInMB} MB boyutunu aşıyor ve kaldırıldı: {fileNames}",
|
||||
"file_size_exceeded_alert": "Dosya {maxSizeInMB} MB'den küçük olmalıdır",
|
||||
"invalid_file_name": "Dosya adı geçersiz karakterler içeriyor. Lütfen dosyayı yeniden adlandırıp tekrar deneyin.",
|
||||
"no_valid_file_types_selected": "Geçerli dosya türü seçilmedi. Lütfen geçerli bir dosya türü seçin.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Aynı anda yalnızca bir dosya yüklenebilir.",
|
||||
"placeholder_text": "Dosyaları yüklemek için tıklayın veya sürükleyin",
|
||||
"upload_failed": "Yükleme başarısız oldu! Lütfen tekrar deneyin.",
|
||||
"upload_service_unavailable": "Dosya yükleme hizmeti kullanılamıyor. Lütfen daha sonra tekrar deneyin veya anket sahibiyle iletişime geçin.",
|
||||
"uploading": "Yükleniyor...",
|
||||
"you_can_only_upload_a_maximum_of_files": "En fazla {FILE_LIMIT} dosya yükleyebilirsiniz."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "Quyidagi fayllar allaqachon yuklangan: {duplicateNames}. Takroriy fayllarga ruxsat berilmaydi.",
|
||||
"file_size_exceeded": "Quyidagi fayl(lar) {maxSizeInMB} MB maksimal hajmdan oshib ketdi va olib tashlandi: {fileNames}",
|
||||
"file_size_exceeded_alert": "Fayl hajmi {maxSizeInMB} MB dan kam bo'lishi kerak",
|
||||
"invalid_file_name": "Fayl nomida yaroqsiz belgilar bor. Fayl nomini o‘zgartirib, qayta urinib ko‘ring.",
|
||||
"no_valid_file_types_selected": "Hech qanday yaroqli fayl turi tanlanmadi. Iltimos, yaroqli fayl turini tanlang.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Bir vaqtning o'zida faqat bitta fayl yuklanishi mumkin.",
|
||||
"placeholder_text": "Fayllarni yuklash uchun bosing yoki sudrab olib keling",
|
||||
"upload_failed": "Yuklash muvaffaqiyatsiz tugadi! Iltimos, qayta urinib ko'ring.",
|
||||
"upload_service_unavailable": "Fayl yuklash xizmati mavjud emas. Keyinroq qayta urinib ko‘ring yoki so‘rovnoma egasiga murojaat qiling.",
|
||||
"uploading": "Yuklanmoqda...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Siz faqat {FILE_LIMIT} ta faylni maksimal yuklashingiz mumkin."
|
||||
},
|
||||
|
||||
@@ -46,10 +46,12 @@
|
||||
"duplicate_files": "以下文件已上传:{duplicateNames}。不允许重复文件。",
|
||||
"file_size_exceeded": "以下文件超过了最大大小 {maxSizeInMB} MB,已被移除:{fileNames}",
|
||||
"file_size_exceeded_alert": "文件应小于 {maxSizeInMB} MB",
|
||||
"invalid_file_name": "文件名包含无效字符。请重命名文件后重试。",
|
||||
"no_valid_file_types_selected": "未选择有效的文件类型。请选择有效的文件类型。",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "一次只能上传一个文件。",
|
||||
"placeholder_text": "点击或拖拽上传文件",
|
||||
"upload_failed": "上传失败!请重试。",
|
||||
"upload_service_unavailable": "文件上传服务不可用。请稍后重试,或联系调查问卷所有者。",
|
||||
"uploading": "上传中...",
|
||||
"you_can_only_upload_a_maximum_of_files": "您最多只能上传 {FILE_LIMIT} 个文件。"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileUpload, type UploadedFile } from "@formbricks/survey-ui";
|
||||
import { FILE_UPLOAD_ERROR_NAMES } from "@formbricks/types/errors";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import type { TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
|
||||
@@ -284,10 +285,15 @@ export function FileUploadElement({
|
||||
setFileErrorMessage(undefined);
|
||||
} catch (err: any) {
|
||||
// Handle upload errors
|
||||
if (err?.name === "FileTooLargeError") {
|
||||
if (err?.name === FILE_UPLOAD_ERROR_NAMES.FILE_TOO_LARGE) {
|
||||
setFileErrorMessage(err.message);
|
||||
} else if (err?.name === "InvalidFileNameError") {
|
||||
setFileErrorMessage(t("errors.file_input.upload_failed"));
|
||||
} else if (err?.name === FILE_UPLOAD_ERROR_NAMES.INVALID_FILE_NAME) {
|
||||
setFileErrorMessage(t("errors.file_input.invalid_file_name"));
|
||||
} else if (
|
||||
err?.name === FILE_UPLOAD_ERROR_NAMES.STORAGE_NOT_CONFIGURED ||
|
||||
err?.name === FILE_UPLOAD_ERROR_NAMES.STORAGE_UPLOAD_FAILED
|
||||
) {
|
||||
setFileErrorMessage(t("errors.file_input.upload_service_unavailable"));
|
||||
} else {
|
||||
setFileErrorMessage(t("errors.file_input.upload_failed"));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { FILE_UPLOAD_ERROR_NAMES } from "@formbricks/types/errors";
|
||||
import { STORAGE_ERROR_CODES } from "@formbricks/types/storage";
|
||||
import { ApiClient } from "./api-client";
|
||||
|
||||
describe("ApiClient", () => {
|
||||
@@ -202,6 +204,26 @@ describe("ApiClient", () => {
|
||||
).rejects.toThrow("Invalid file name");
|
||||
});
|
||||
|
||||
test("throws StorageNotConfiguredError if signing fails because storage is not configured", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({
|
||||
code: "internal_server_error",
|
||||
message: "File storage is not configured correctly. Please check your file upload settings.",
|
||||
details: { storage_error_code: STORAGE_ERROR_CODES.S3_CLIENT_ERROR },
|
||||
}),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(() =>
|
||||
client.uploadFile({
|
||||
base64: "data:image/jpeg;base64,abcd",
|
||||
name: "test.jpg",
|
||||
type: "image/jpeg",
|
||||
})
|
||||
).rejects.toMatchObject({ name: FILE_UPLOAD_ERROR_NAMES.STORAGE_NOT_CONFIGURED });
|
||||
});
|
||||
|
||||
test("throws an error if actual upload fails", async () => {
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -230,6 +252,31 @@ describe("ApiClient", () => {
|
||||
).rejects.toThrow("Upload failed with status: 500");
|
||||
});
|
||||
|
||||
test("throws StorageUploadFailedError if actual upload request fails before response", async () => {
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
signedUrl: "https://fake-s3-url.com",
|
||||
fileUrl: "https://fake-file-url.com",
|
||||
presignedFields: { policy: "test" },
|
||||
signingData: null,
|
||||
updatedFileName: "test.jpg",
|
||||
},
|
||||
}),
|
||||
} as unknown as Response)
|
||||
.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
await expect(() =>
|
||||
client.uploadFile({
|
||||
base64: "data:image/jpeg;base64,abcd",
|
||||
name: "test.jpg",
|
||||
type: "image/jpeg",
|
||||
})
|
||||
).rejects.toMatchObject({ name: FILE_UPLOAD_ERROR_NAMES.STORAGE_UPLOAD_FAILED });
|
||||
});
|
||||
|
||||
test('throws "Error uploading file" if base64 is invalid', async () => {
|
||||
// Mock the initial "signing" fetch to succeed
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { TDisplayCreateInput } from "@formbricks/types/displays";
|
||||
import { Result } from "@formbricks/types/error-handlers";
|
||||
import { ApiErrorResponse } from "@formbricks/types/errors";
|
||||
import { type ApiErrorResponse, FILE_UPLOAD_ERROR_NAMES } from "@formbricks/types/errors";
|
||||
import { TSurveyQuotaAction } from "@formbricks/types/quota";
|
||||
import { TResponseInput, TResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { TUploadFileConfig, TUploadFileResponse } from "@formbricks/types/storage";
|
||||
import {
|
||||
STORAGE_CONFIGURATION_ERROR_CODES,
|
||||
type TStorageApiErrorDetails,
|
||||
type TUploadFileConfig,
|
||||
type TUploadFileResponse,
|
||||
} from "@formbricks/types/storage";
|
||||
import { makeRequest } from "@/lib/utils";
|
||||
|
||||
type TResponseCreateResponseQuotaFull = {
|
||||
@@ -23,6 +28,18 @@ type TResponseCreateResponse = {
|
||||
|
||||
type TResponseUpdateResponse = Record<string, unknown> & TResponseQuota;
|
||||
|
||||
type TUploadApiErrorResponse = ApiErrorResponse & {
|
||||
details?: ApiErrorResponse["details"] & TStorageApiErrorDetails;
|
||||
};
|
||||
|
||||
const parseUploadErrorResponse = async (response: Response): Promise<TUploadApiErrorResponse | undefined> => {
|
||||
try {
|
||||
return (await response.json()) as TUploadApiErrorResponse;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Simple API client using fetch
|
||||
export class ApiClient {
|
||||
readonly appUrl: string;
|
||||
@@ -121,13 +138,22 @@ export class ApiClient {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 400) {
|
||||
const json = (await response.json()) as ApiErrorResponse;
|
||||
if (json.details?.fileName) {
|
||||
const err = new Error("Invalid file name");
|
||||
err.name = "InvalidFileNameError";
|
||||
throw err;
|
||||
}
|
||||
const json = await parseUploadErrorResponse(response);
|
||||
|
||||
if (response.status === 400 && json?.details?.fileName) {
|
||||
const err = new Error("Invalid file name");
|
||||
err.name = FILE_UPLOAD_ERROR_NAMES.INVALID_FILE_NAME;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (
|
||||
response.status >= 500 &&
|
||||
json?.details?.storage_error_code &&
|
||||
STORAGE_CONFIGURATION_ERROR_CODES.has(json.details.storage_error_code)
|
||||
) {
|
||||
const err = new Error("File upload service is not configured");
|
||||
err.name = FILE_UPLOAD_ERROR_NAMES.STORAGE_NOT_CONFIGURED;
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw new Error(`Upload failed with status: ${String(response.status)}`);
|
||||
@@ -173,7 +199,9 @@ export class ApiClient {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error uploading file", err);
|
||||
throw new Error("Network error while uploading file");
|
||||
const error = new Error("File upload service is unavailable");
|
||||
error.name = FILE_UPLOAD_ERROR_NAMES.STORAGE_UPLOAD_FAILED;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
@@ -181,11 +209,13 @@ export class ApiClient {
|
||||
|
||||
if (presignedFields && errorText.includes("EntityTooLarge")) {
|
||||
const error = new Error("File size exceeds the size limit for your plan");
|
||||
error.name = "FileTooLargeError";
|
||||
error.name = FILE_UPLOAD_ERROR_NAMES.FILE_TOO_LARGE;
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(`Upload failed with status: ${String(uploadResponse.status)}`);
|
||||
const error = new Error(`Upload failed with status: ${String(uploadResponse.status)}`);
|
||||
error.name = FILE_UPLOAD_ERROR_NAMES.STORAGE_UPLOAD_FAILED;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return fileUrl;
|
||||
|
||||
@@ -147,6 +147,13 @@ export {
|
||||
};
|
||||
export type { NetworkError, ForbiddenError };
|
||||
|
||||
export const FILE_UPLOAD_ERROR_NAMES = {
|
||||
INVALID_FILE_NAME: "InvalidFileNameError",
|
||||
STORAGE_NOT_CONFIGURED: "StorageNotConfiguredError",
|
||||
STORAGE_UPLOAD_FAILED: "StorageUploadFailedError",
|
||||
FILE_TOO_LARGE: "FileTooLargeError",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Error names that represent expected business-logic failures.
|
||||
* These are handled gracefully in the UI and should NOT be reported to Sentry.
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { z } from "zod";
|
||||
import { ZStorageUrl } from "./common";
|
||||
|
||||
export const STORAGE_ERROR_CODES = {
|
||||
S3_CREDENTIALS_ERROR: "s3_credentials_error",
|
||||
S3_CLIENT_ERROR: "s3_client_error",
|
||||
} as const;
|
||||
|
||||
export const STORAGE_CONFIGURATION_ERROR_CODES = new Set<string>([
|
||||
STORAGE_ERROR_CODES.S3_CREDENTIALS_ERROR,
|
||||
STORAGE_ERROR_CODES.S3_CLIENT_ERROR,
|
||||
]);
|
||||
|
||||
export type TStorageConfigurationErrorCode = (typeof STORAGE_ERROR_CODES)[keyof typeof STORAGE_ERROR_CODES];
|
||||
|
||||
export interface TStorageApiErrorDetails {
|
||||
fileName?: string;
|
||||
storage_error_code?: string;
|
||||
}
|
||||
|
||||
// Single source of truth for allowed file extensions
|
||||
const ALLOWED_FILE_EXTENSIONS_TUPLE = [
|
||||
"heic",
|
||||
|
||||
@@ -289,8 +289,6 @@
|
||||
"RECAPTCHA_SECRET_KEY",
|
||||
"TELEMETRY_DISABLED",
|
||||
"TERMS_URL",
|
||||
"VERCEL",
|
||||
"VERCEL_URL",
|
||||
"VERSION",
|
||||
"WEBAPP_URL",
|
||||
"UNSPLASH_ACCESS_KEY",
|
||||
|
||||
Reference in New Issue
Block a user