mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-24 17:33:26 -05:00
Compare commits
5 Commits
useClickOu
...
docs/formb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
762d40520c | ||
|
|
e90a558f20 | ||
|
|
f3a581376b | ||
|
|
fb0cfb1eb4 | ||
|
|
5a723cd81a |
6
.github/workflows/move-stable-tag.yml
vendored
6
.github/workflows/move-stable-tag.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "The release tag name (e.g., 1.2.3)"
|
||||
description: "The release tag name (e.g., v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
commit_sha:
|
||||
@@ -53,8 +53,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
# Validate release tag format
|
||||
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid release tag format. Expected format: 1.2.3, 1.2.3-alpha"
|
||||
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid release tag format. Expected format: v1.2.3, v1.2.3-alpha"
|
||||
echo "Provided: $RELEASE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
|
||||
import { responses } from "./response";
|
||||
|
||||
// Mocks
|
||||
@@ -14,10 +14,6 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn((callback) => {
|
||||
callback(mockSentryScope);
|
||||
return mockSentryScope;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
|
||||
@@ -25,14 +21,6 @@ const mockContextualLoggerError = vi.fn();
|
||||
const mockContextualLoggerWarn = vi.fn();
|
||||
const mockContextualLoggerInfo = vi.fn();
|
||||
|
||||
// Mock Sentry scope that can be referenced in tests
|
||||
const mockSentryScope = {
|
||||
setTag: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@formbricks/logger", () => {
|
||||
const mockWithContextInstance = vi.fn(() => ({
|
||||
error: mockContextualLoggerError,
|
||||
@@ -122,12 +110,6 @@ describe("withV1ApiWrapper", () => {
|
||||
}));
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mock Sentry scope calls
|
||||
mockSentryScope.setTag.mockClear();
|
||||
mockSentryScope.setExtra.mockClear();
|
||||
mockSentryScope.setContext.mockClear();
|
||||
mockSentryScope.setLevel.mockClear();
|
||||
});
|
||||
|
||||
test("logs and audits on error response with API key authentication", async () => {
|
||||
@@ -179,9 +161,10 @@ describe("withV1ApiWrapper", () => {
|
||||
organizationId: "org-1",
|
||||
})
|
||||
);
|
||||
expect(Sentry.withScope).toHaveBeenCalled();
|
||||
expect(mockSentryScope.setExtra).toHaveBeenCalledWith("originalError", undefined);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "abc-123" }) })
|
||||
);
|
||||
});
|
||||
|
||||
test("does not log Sentry if not 500", async () => {
|
||||
@@ -286,8 +269,10 @@ describe("withV1ApiWrapper", () => {
|
||||
organizationId: "org-1",
|
||||
})
|
||||
);
|
||||
expect(Sentry.withScope).toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ extra: expect.objectContaining({ correlationId: "err-1" }) })
|
||||
);
|
||||
});
|
||||
|
||||
test("does not log on success response but still audits", async () => {
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Session, getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
@@ -19,6 +14,11 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditAction, TAuditTarget, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { Session, getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
|
||||
export type TApiAuditLog = Parameters<typeof queueAuditEvent>[0];
|
||||
export type TApiV1Authentication = TAuthenticationApiKey | Session | null;
|
||||
@@ -173,21 +173,8 @@ const logErrorDetails = (res: Response, req: NextRequest, correlationId: string,
|
||||
logger.withContext(logContext).error("V1 API Error Details");
|
||||
|
||||
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
|
||||
// Set correlation ID as a tag for easy filtering
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("correlationId", correlationId);
|
||||
scope.setLevel("error");
|
||||
|
||||
// If we have an actual error, capture it with full stacktrace
|
||||
// Otherwise, create a generic error with context
|
||||
if (error instanceof Error) {
|
||||
Sentry.captureException(error);
|
||||
} else {
|
||||
scope.setExtra("originalError", error);
|
||||
const genericError = new Error(`API V1 error, id: ${correlationId}`);
|
||||
Sentry.captureException(genericError);
|
||||
}
|
||||
});
|
||||
const err = new Error(`API V1 error, id: ${correlationId}`);
|
||||
Sentry.captureException(err, { extra: { error, correlationId } });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ export const MAX_FILE_UPLOAD_SIZES = {
|
||||
standard: 1024 * 1024 * 10, // 10MB
|
||||
big: 1024 * 1024 * 1024, // 1GB
|
||||
} as const;
|
||||
export const IS_STORAGE_CONFIGURED = Boolean(S3_BUCKET_NAME);
|
||||
export const IS_STORAGE_CONFIGURED = Boolean(S3_ACCESS_KEY && S3_SECRET_KEY && S3_REGION && S3_BUCKET_NAME);
|
||||
|
||||
// Colors for Survey Bg
|
||||
export const SURVEY_BG_COLORS = [
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { env } from "@/lib/env";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import {
|
||||
createEmailChangeToken,
|
||||
createEmailToken,
|
||||
@@ -15,69 +14,12 @@ import {
|
||||
verifyTokenForLinkSurvey,
|
||||
} from "./jwt";
|
||||
|
||||
const TEST_ENCRYPTION_KEY = "0".repeat(32); // 32-byte key for AES-256-GCM
|
||||
const TEST_NEXTAUTH_SECRET = "test-nextauth-secret";
|
||||
const DIFFERENT_SECRET = "different-secret";
|
||||
|
||||
// Error message constants
|
||||
const NEXTAUTH_SECRET_ERROR = "NEXTAUTH_SECRET is not set";
|
||||
const ENCRYPTION_KEY_ERROR = "ENCRYPTION_KEY is not set";
|
||||
|
||||
// Helper function to test error cases for missing secrets/keys
|
||||
const testMissingSecretsError = async (
|
||||
testFn: (...args: any[]) => any,
|
||||
args: any[],
|
||||
options: {
|
||||
testNextAuthSecret?: boolean;
|
||||
testEncryptionKey?: boolean;
|
||||
isAsync?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const { testNextAuthSecret = true, testEncryptionKey = true, isAsync = false } = options;
|
||||
|
||||
if (testNextAuthSecret) {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalSecret = (constants as any).NEXTAUTH_SECRET;
|
||||
(constants as any).NEXTAUTH_SECRET = undefined;
|
||||
|
||||
if (isAsync) {
|
||||
await expect(testFn(...args)).rejects.toThrow(NEXTAUTH_SECRET_ERROR);
|
||||
} else {
|
||||
expect(() => testFn(...args)).toThrow(NEXTAUTH_SECRET_ERROR);
|
||||
}
|
||||
|
||||
// Restore
|
||||
(constants as any).NEXTAUTH_SECRET = originalSecret;
|
||||
}
|
||||
|
||||
if (testEncryptionKey) {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalKey = (constants as any).ENCRYPTION_KEY;
|
||||
(constants as any).ENCRYPTION_KEY = undefined;
|
||||
|
||||
if (isAsync) {
|
||||
await expect(testFn(...args)).rejects.toThrow(ENCRYPTION_KEY_ERROR);
|
||||
} else {
|
||||
expect(() => testFn(...args)).toThrow(ENCRYPTION_KEY_ERROR);
|
||||
}
|
||||
|
||||
// Restore
|
||||
(constants as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
};
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENCRYPTION_KEY: "0".repeat(32),
|
||||
ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
ENCRYPTION_KEY: "0".repeat(32),
|
||||
} as typeof env,
|
||||
}));
|
||||
|
||||
// Mock prisma
|
||||
@@ -89,65 +31,22 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
describe("JWT Functions", () => {
|
||||
const mockUser = {
|
||||
id: "test-user-id",
|
||||
email: "test@example.com",
|
||||
};
|
||||
|
||||
let mockSymmetricEncrypt: any;
|
||||
let mockSymmetricDecrypt: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default crypto mocks
|
||||
mockSymmetricEncrypt = vi
|
||||
.spyOn(crypto, "symmetricEncrypt")
|
||||
.mockImplementation((text: string) => `encrypted_${text}`);
|
||||
|
||||
mockSymmetricDecrypt = vi
|
||||
.spyOn(crypto, "symmetricDecrypt")
|
||||
.mockImplementation((encryptedText: string) => encryptedText.replace("encrypted_", ""));
|
||||
|
||||
(prisma.user.findUnique as any).mockResolvedValue(mockUser);
|
||||
});
|
||||
|
||||
describe("createToken", () => {
|
||||
test("should create a valid token with encrypted user ID", () => {
|
||||
const token = createToken(mockUser.id);
|
||||
test("should create a valid token", () => {
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should accept custom options", () => {
|
||||
const customOptions = { expiresIn: "1h" };
|
||||
const token = createToken(mockUser.id, customOptions);
|
||||
expect(token).toBeDefined();
|
||||
|
||||
// Verify the token contains the expected expiration
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
// Should expire in approximately 1 hour (3600 seconds)
|
||||
expect(decoded.exp - decoded.iat).toBe(3600);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
|
||||
await testMissingSecretsError(createToken, [mockUser.id], {
|
||||
testNextAuthSecret: true,
|
||||
testEncryptionKey: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,18 +56,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should include surveyId in payload", () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.surveyId).toBe(surveyId);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createTokenForLinkSurvey, ["survey-id", mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,30 +64,24 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createEmailToken, [mockUser.email]);
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", () => {
|
||||
const originalSecret = env.NEXTAUTH_SECRET;
|
||||
try {
|
||||
(env as any).NEXTAUTH_SECRET = undefined;
|
||||
expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set");
|
||||
} finally {
|
||||
(env as any).NEXTAUTH_SECRET = originalSecret;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEmailChangeToken", () => {
|
||||
test("should create a valid email change token with 1 day expiration", () => {
|
||||
const token = createEmailChangeToken(mockUser.id, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
// Should expire in approximately 1 day (86400 seconds)
|
||||
expect(decoded.exp - decoded.iat).toBe(86400);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createEmailChangeToken, [mockUser.id, mockUser.email]);
|
||||
describe("getEmailFromEmailToken", () => {
|
||||
test("should extract email from valid token", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
const extractedEmail = getEmailFromEmailToken(token);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,50 +91,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
const token = createInviteToken(inviteId, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(inviteId, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should accept custom options", () => {
|
||||
const inviteId = "test-invite-id";
|
||||
const customOptions = { expiresIn: "24h" };
|
||||
const token = createInviteToken(inviteId, mockUser.email, customOptions);
|
||||
expect(token).toBeDefined();
|
||||
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
// Should expire in approximately 24 hours (86400 seconds)
|
||||
expect(decoded.exp - decoded.iat).toBe(86400);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createInviteToken, ["invite-id", mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmailFromEmailToken", () => {
|
||||
test("should extract email from valid token", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
const extractedEmail = getEmailFromEmailToken(token);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
expect(mockSymmetricDecrypt).toHaveBeenCalledWith(`encrypted_${mockUser.email}`, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should fall back to original email if decryption fails", () => {
|
||||
mockSymmetricDecrypt.mockImplementationOnce(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
// Create token manually with unencrypted email for legacy compatibility
|
||||
const legacyToken = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
|
||||
const extractedEmail = getEmailFromEmailToken(legacyToken);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
|
||||
await testMissingSecretsError(getEmailFromEmailToken, [token]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,194 +106,23 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null if NEXTAUTH_SECRET is not set", async () => {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalSecret = (constants as any).NEXTAUTH_SECRET;
|
||||
(constants as any).NEXTAUTH_SECRET = undefined;
|
||||
|
||||
const result = verifyTokenForLinkSurvey("any-token", "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
|
||||
// Restore
|
||||
(constants as any).NEXTAUTH_SECRET = originalSecret;
|
||||
});
|
||||
|
||||
test("should return null if surveyId doesn't match", () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const differentSurveyId = "different-survey-id";
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
const result = verifyTokenForLinkSurvey(token, differentSurveyId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null if email is missing from payload", () => {
|
||||
const tokenWithoutEmail = jwt.sign({ surveyId: "test-survey-id" }, TEST_NEXTAUTH_SECRET);
|
||||
const result = verifyTokenForLinkSurvey(tokenWithoutEmail, "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should fall back to original email if decryption fails", () => {
|
||||
mockSymmetricDecrypt.mockImplementationOnce(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
// Create legacy token with unencrypted email
|
||||
const legacyToken = jwt.sign(
|
||||
{
|
||||
email: mockUser.email,
|
||||
surveyId: "test-survey-id",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(legacyToken, "test-survey-id");
|
||||
expect(result).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should fall back to original email if ENCRYPTION_KEY is not set", async () => {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalKey = (constants as any).ENCRYPTION_KEY;
|
||||
(constants as any).ENCRYPTION_KEY = undefined;
|
||||
|
||||
// Create a token with unencrypted email (as it would be if ENCRYPTION_KEY was not set during creation)
|
||||
const token = jwt.sign(
|
||||
{
|
||||
email: mockUser.email,
|
||||
surveyId: "survey-id",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(token, "survey-id");
|
||||
expect(result).toBe(mockUser.email);
|
||||
|
||||
// Restore
|
||||
(constants as any).ENCRYPTION_KEY = originalKey;
|
||||
});
|
||||
|
||||
test("should verify legacy survey tokens with surveyId-based secret", async () => {
|
||||
const surveyId = "test-survey-id";
|
||||
|
||||
// Create legacy token with old format (NEXTAUTH_SECRET + surveyId)
|
||||
const legacyToken = jwt.sign({ email: `encrypted_${mockUser.email}` }, TEST_NEXTAUTH_SECRET + surveyId);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(legacyToken, surveyId);
|
||||
expect(result).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should reject survey tokens that fail both new and legacy verification", async () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const invalidToken = jwt.sign({ email: "encrypted_test@example.com" }, "wrong-secret");
|
||||
|
||||
const result = verifyTokenForLinkSurvey(invalidToken, surveyId);
|
||||
expect(result).toBeNull();
|
||||
|
||||
// Verify error logging
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Survey link token verification failed");
|
||||
});
|
||||
|
||||
test("should reject legacy survey tokens for wrong survey", () => {
|
||||
const correctSurveyId = "correct-survey-id";
|
||||
const wrongSurveyId = "wrong-survey-id";
|
||||
|
||||
// Create legacy token for one survey
|
||||
const legacyToken = jwt.sign(
|
||||
{ email: `encrypted_${mockUser.email}` },
|
||||
TEST_NEXTAUTH_SECRET + correctSurveyId
|
||||
);
|
||||
|
||||
// Try to verify with different survey ID
|
||||
const result = verifyTokenForLinkSurvey(legacyToken, wrongSurveyId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyToken", () => {
|
||||
test("should verify valid token", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
const verified = await verifyToken(token);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error if user not found", async () => {
|
||||
(prisma.user.findUnique as any).mockResolvedValue(null);
|
||||
const token = createToken(mockUser.id);
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
await expect(verifyToken(token)).rejects.toThrow("User not found");
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
|
||||
await testMissingSecretsError(verifyToken, ["any-token"], {
|
||||
testNextAuthSecret: true,
|
||||
testEncryptionKey: false,
|
||||
isAsync: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for invalid token signature", async () => {
|
||||
const invalidToken = jwt.sign({ id: "test-id" }, DIFFERENT_SECRET);
|
||||
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should throw error if token payload is missing id", async () => {
|
||||
const tokenWithoutId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyToken(tokenWithoutId)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should return raw id from payload", async () => {
|
||||
// Create token with unencrypted id
|
||||
const token = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
|
||||
const verified = await verifyToken(token);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the raw ID from payload
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should verify legacy tokens with email-based secret", async () => {
|
||||
// Create legacy token with old format (NEXTAUTH_SECRET + userEmail)
|
||||
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
|
||||
|
||||
const verified = await verifyToken(legacyToken);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should prioritize new tokens over legacy tokens", async () => {
|
||||
// Create both new and legacy tokens for the same user
|
||||
const newToken = createToken(mockUser.id);
|
||||
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
|
||||
|
||||
// New token should verify without triggering legacy path
|
||||
const verifiedNew = await verifyToken(newToken);
|
||||
expect(verifiedNew.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
|
||||
// Legacy token should trigger legacy path
|
||||
const verifiedLegacy = await verifyToken(legacyToken);
|
||||
expect(verifiedLegacy.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
});
|
||||
|
||||
test("should reject tokens that fail both new and legacy verification", async () => {
|
||||
const invalidToken = jwt.sign({ id: "encrypted_test-id" }, "wrong-secret");
|
||||
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
|
||||
|
||||
// Verify both methods were attempted
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Token verification failed with new method"
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Token verification failed with legacy method"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyInviteToken", () => {
|
||||
@@ -473,53 +139,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
test("should throw error for invalid token", () => {
|
||||
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(verifyInviteToken, ["any-token"]);
|
||||
});
|
||||
|
||||
test("should throw error if inviteId is missing", () => {
|
||||
const tokenWithoutInviteId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
|
||||
expect(() => verifyInviteToken(tokenWithoutInviteId)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should throw error if email is missing", () => {
|
||||
const tokenWithoutEmail = jwt.sign({ inviteId: "test-invite-id" }, TEST_NEXTAUTH_SECRET);
|
||||
expect(() => verifyInviteToken(tokenWithoutEmail)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should fall back to original values if decryption fails", () => {
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
const inviteId = "test-invite-id";
|
||||
const legacyToken = jwt.sign(
|
||||
{
|
||||
inviteId,
|
||||
email: mockUser.email,
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const verified = verifyInviteToken(legacyToken);
|
||||
expect(verified).toEqual({
|
||||
inviteId,
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for token with wrong signature", () => {
|
||||
const invalidToken = jwt.sign(
|
||||
{
|
||||
inviteId: "test-invite-id",
|
||||
email: mockUser.email,
|
||||
},
|
||||
DIFFERENT_SECRET
|
||||
);
|
||||
|
||||
expect(() => verifyInviteToken(invalidToken)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyEmailChangeToken", () => {
|
||||
@@ -531,478 +150,22 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
expect(result).toEqual({ id: userId, email });
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(verifyEmailChangeToken, ["any-token"], { isAsync: true });
|
||||
});
|
||||
|
||||
test("should throw error if token is invalid or missing fields", async () => {
|
||||
const token = jwt.sign({ foo: "bar" }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error if id is missing", async () => {
|
||||
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error if email is missing", async () => {
|
||||
const token = jwt.sign({ id: "test-id" }, TEST_NEXTAUTH_SECRET);
|
||||
// Create a token with missing fields
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return original id/email if decryption fails", async () => {
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
// Create a token with non-encrypted id/email
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const payload = { id: "plain-id", email: "plain@example.com" };
|
||||
const token = jwt.sign(payload, TEST_NEXTAUTH_SECRET);
|
||||
const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string);
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
test("should throw error for token with wrong signature", async () => {
|
||||
const invalidToken = jwt.sign(
|
||||
{
|
||||
id: "test-id",
|
||||
email: "test@example.com",
|
||||
},
|
||||
DIFFERENT_SECRET
|
||||
);
|
||||
|
||||
await expect(verifyEmailChangeToken(invalidToken)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// SECURITY SCENARIO TESTS
|
||||
describe("Security Scenarios", () => {
|
||||
describe("Algorithm Confusion Attack Prevention", () => {
|
||||
test("should reject 'none' algorithm tokens in verifyToken", async () => {
|
||||
// Create malicious token with "none" algorithm
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_malicious-id",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject 'none' algorithm tokens in verifyTokenForLinkSurvey", () => {
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
email: "encrypted_attacker@evil.com",
|
||||
surveyId: "test-survey-id",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
const result = verifyTokenForLinkSurvey(maliciousToken, "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should reject 'none' algorithm tokens in verifyInviteToken", () => {
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
inviteId: "encrypted_malicious-invite",
|
||||
email: "encrypted_attacker@evil.com",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
expect(() => verifyInviteToken(maliciousToken)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should reject 'none' algorithm tokens in verifyEmailChangeToken", async () => {
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_malicious-id",
|
||||
email: "encrypted_attacker@evil.com",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
await expect(verifyEmailChangeToken(maliciousToken)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should reject RS256 algorithm tokens (HS256/RS256 confusion)", async () => {
|
||||
// Create malicious token with RS256 algorithm header but HS256 signature
|
||||
const maliciousHeader = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
const maliciousPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_malicious-id",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
// Create signature using HMAC (as if it were HS256)
|
||||
const crypto = require("crypto");
|
||||
const signature = crypto
|
||||
.createHmac("sha256", TEST_NEXTAUTH_SECRET)
|
||||
.update(`${maliciousHeader}.${maliciousPayload}`)
|
||||
.digest("base64url");
|
||||
|
||||
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.${signature}`;
|
||||
|
||||
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should only accept HS256 algorithm", async () => {
|
||||
// Test that other valid algorithms are rejected
|
||||
const otherAlgorithms = ["HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"];
|
||||
|
||||
for (const alg of otherAlgorithms) {
|
||||
const maliciousHeader = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg,
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
const maliciousPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_test-id",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.fake-signature`;
|
||||
|
||||
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Tampering", () => {
|
||||
test("should reject tokens with modified payload", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
const [header, payload, signature] = token.split(".");
|
||||
|
||||
// Modify the payload
|
||||
const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString());
|
||||
decodedPayload.id = "malicious-id";
|
||||
const tamperedPayload = Buffer.from(JSON.stringify(decodedPayload)).toString("base64url");
|
||||
const tamperedToken = `${header}.${tamperedPayload}.${signature}`;
|
||||
|
||||
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject tokens with modified signature", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
const [header, payload] = token.split(".");
|
||||
const tamperedToken = `${header}.${payload}.tamperedsignature`;
|
||||
|
||||
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject malformed tokens", async () => {
|
||||
const malformedTokens = [
|
||||
"not.a.jwt",
|
||||
"only.two.parts",
|
||||
"too.many.parts.here.invalid",
|
||||
"",
|
||||
"invalid-base64",
|
||||
];
|
||||
|
||||
for (const malformedToken of malformedTokens) {
|
||||
await expect(verifyToken(malformedToken)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cross-Survey Token Reuse", () => {
|
||||
test("should reject survey tokens used for different surveys", () => {
|
||||
const surveyId1 = "survey-1";
|
||||
const surveyId2 = "survey-2";
|
||||
|
||||
const token = createTokenForLinkSurvey(surveyId1, mockUser.email);
|
||||
const result = verifyTokenForLinkSurvey(token, surveyId2);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Expired Tokens", () => {
|
||||
test("should reject expired tokens", async () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{
|
||||
id: "encrypted_test-id",
|
||||
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
await expect(verifyToken(expiredToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject expired email change tokens", async () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{
|
||||
id: "encrypted_test-id",
|
||||
email: "encrypted_test@example.com",
|
||||
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
await expect(verifyEmailChangeToken(expiredToken)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Encryption Key Attacks", () => {
|
||||
test("should fail gracefully with wrong encryption key", async () => {
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Authentication tag verification failed");
|
||||
});
|
||||
|
||||
// Mock findUnique to only return user for correct decrypted ID, not ciphertext
|
||||
(prisma.user.findUnique as any).mockImplementation(({ where }: { where: { id: string } }) => {
|
||||
if (where.id === mockUser.id) {
|
||||
return Promise.resolve(mockUser);
|
||||
}
|
||||
return Promise.resolve(null); // Return null for ciphertext IDs
|
||||
});
|
||||
|
||||
const token = createToken(mockUser.id);
|
||||
// Should fail because ciphertext passed as userId won't match any user in DB
|
||||
await expect(verifyToken(token)).rejects.toThrow(/User not found/i);
|
||||
});
|
||||
|
||||
test("should handle encryption key not set gracefully", async () => {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalKey = (constants as any).ENCRYPTION_KEY;
|
||||
(constants as any).ENCRYPTION_KEY = undefined;
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
email: "test@example.com",
|
||||
surveyId: "test-survey-id",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(token, "test-survey-id");
|
||||
expect(result).toBe("test@example.com");
|
||||
|
||||
// Restore
|
||||
(constants as any).ENCRYPTION_KEY = originalKey;
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection Attempts", () => {
|
||||
test("should safely handle malicious user IDs", async () => {
|
||||
const maliciousIds = [
|
||||
"'; DROP TABLE users; --",
|
||||
"1' OR '1'='1",
|
||||
"admin'/*",
|
||||
"<script>alert('xss')</script>",
|
||||
"../../etc/passwd",
|
||||
];
|
||||
|
||||
for (const maliciousId of maliciousIds) {
|
||||
mockSymmetricDecrypt.mockReturnValueOnce(maliciousId);
|
||||
|
||||
const token = jwt.sign({ id: "encrypted_malicious" }, TEST_NEXTAUTH_SECRET);
|
||||
|
||||
// The function should look up the user safely
|
||||
await verifyToken(token);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: maliciousId },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Reuse and Replay Attacks", () => {
|
||||
test("should allow legitimate token reuse within validity period", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
|
||||
// First use
|
||||
const result1 = await verifyToken(token);
|
||||
expect(result1.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
|
||||
// Second use (should still work)
|
||||
const result2 = await verifyToken(token);
|
||||
expect(result2.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
});
|
||||
});
|
||||
|
||||
describe("Legacy Token Compatibility", () => {
|
||||
test("should handle legacy unencrypted tokens gracefully", async () => {
|
||||
// Legacy token with plain text data
|
||||
const legacyToken = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
|
||||
const result = await verifyToken(legacyToken);
|
||||
|
||||
expect(result.id).toBe(mockUser.id); // Returns raw ID from payload
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should handle mixed encrypted/unencrypted fields", async () => {
|
||||
mockSymmetricDecrypt
|
||||
.mockImplementationOnce(() => mockUser.id) // id decrypts successfully
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("Email not encrypted");
|
||||
}); // email fails
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: "encrypted_test-id",
|
||||
email: "plain-email@example.com",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
expect(result.id).toBe(mockUser.id);
|
||||
expect(result.email).toBe("plain-email@example.com");
|
||||
});
|
||||
|
||||
test("should verify old format user tokens with email-based secrets", async () => {
|
||||
// Simulate old token format with per-user secret
|
||||
const oldFormatToken = jwt.sign(
|
||||
{ id: `encrypted_${mockUser.id}` },
|
||||
TEST_NEXTAUTH_SECRET + mockUser.email
|
||||
);
|
||||
|
||||
const result = await verifyToken(oldFormatToken);
|
||||
expect(result.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should verify old format survey tokens with survey-based secrets", () => {
|
||||
const surveyId = "legacy-survey-id";
|
||||
|
||||
// Simulate old survey token format
|
||||
const oldFormatSurveyToken = jwt.sign(
|
||||
{ email: `encrypted_${mockUser.email}` },
|
||||
TEST_NEXTAUTH_SECRET + surveyId
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(oldFormatSurveyToken, surveyId);
|
||||
expect(result).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should gracefully handle database errors during legacy verification", async () => {
|
||||
// Create token that will fail new method
|
||||
const legacyToken = jwt.sign(
|
||||
{ id: `encrypted_${mockUser.id}` },
|
||||
TEST_NEXTAUTH_SECRET + mockUser.email
|
||||
);
|
||||
|
||||
// Make database lookup fail
|
||||
(prisma.user.findUnique as any).mockRejectedValueOnce(new Error("DB connection lost"));
|
||||
|
||||
await expect(verifyToken(legacyToken)).rejects.toThrow("DB connection lost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases and Error Handling", () => {
|
||||
test("should handle database connection errors gracefully", async () => {
|
||||
(prisma.user.findUnique as any).mockRejectedValue(new Error("Database connection failed"));
|
||||
|
||||
const token = createToken(mockUser.id);
|
||||
await expect(verifyToken(token)).rejects.toThrow("Database connection failed");
|
||||
});
|
||||
|
||||
test("should handle crypto module errors", () => {
|
||||
mockSymmetricEncrypt.mockImplementation(() => {
|
||||
throw new Error("Crypto module error");
|
||||
});
|
||||
|
||||
expect(() => createToken(mockUser.id)).toThrow("Crypto module error");
|
||||
});
|
||||
|
||||
test("should validate email format in tokens", () => {
|
||||
const invalidEmails = ["", "not-an-email", "missing@", "@missing-local.com", "spaces in@email.com"];
|
||||
|
||||
invalidEmails.forEach((invalidEmail) => {
|
||||
expect(() => createEmailToken(invalidEmail)).not.toThrow();
|
||||
// Note: JWT functions don't validate email format, they just encrypt/decrypt
|
||||
// Email validation should happen at a higher level
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle extremely long inputs", () => {
|
||||
const longString = "a".repeat(10000);
|
||||
|
||||
expect(() => createToken(longString)).not.toThrow();
|
||||
expect(() => createEmailToken(longString)).not.toThrow();
|
||||
});
|
||||
|
||||
test("should handle special characters in user data", () => {
|
||||
const specialChars = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/~`";
|
||||
|
||||
expect(() => createToken(specialChars)).not.toThrow();
|
||||
expect(() => createEmailToken(specialChars)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance and Resource Exhaustion", () => {
|
||||
test("should handle rapid token creation without memory leaks", () => {
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
tokens.push(createToken(`user-${i}`));
|
||||
}
|
||||
|
||||
expect(tokens.length).toBe(1000);
|
||||
expect(tokens.every((token) => typeof token === "string")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle rapid token verification", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
|
||||
const verifications: Promise<any>[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
verifications.push(verifyToken(token));
|
||||
}
|
||||
|
||||
const results = await Promise.all(verifications);
|
||||
expect(results.length).toBe(100);
|
||||
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,64 +1,43 @@
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { env } from "@/lib/env";
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
|
||||
// Helper function to decrypt with fallback to plain text
|
||||
const decryptWithFallback = (encryptedText: string, key: string): string => {
|
||||
try {
|
||||
return symmetricDecrypt(encryptedText, key);
|
||||
} catch {
|
||||
return encryptedText; // Return as-is if decryption fails (legacy format)
|
||||
}
|
||||
};
|
||||
|
||||
export const createToken = (userId: string, options = {}): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
|
||||
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
|
||||
export const createToken = (userId: string, userEmail: string, options = {}): string => {
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
|
||||
};
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(userEmail, ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail, surveyId }, NEXTAUTH_SECRET);
|
||||
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
|
||||
};
|
||||
|
||||
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string };
|
||||
|
||||
if (!payload?.id || !payload?.email) {
|
||||
throw new Error("Token is invalid or missing required fields");
|
||||
}
|
||||
|
||||
// Decrypt both fields with fallback
|
||||
const decryptedId = decryptWithFallback(payload.id, ENCRYPTION_KEY);
|
||||
const decryptedEmail = decryptWithFallback(payload.email, ENCRYPTION_KEY);
|
||||
let decryptedId: string;
|
||||
let decryptedEmail: string;
|
||||
|
||||
try {
|
||||
decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedId = payload.id;
|
||||
}
|
||||
|
||||
try {
|
||||
decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedEmail = payload.email;
|
||||
}
|
||||
|
||||
return {
|
||||
id: decryptedId,
|
||||
@@ -67,230 +46,127 @@ export const verifyEmailChangeToken = async (token: string): Promise<{ id: strin
|
||||
};
|
||||
|
||||
export const createEmailChangeToken = (userId: string, email: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
|
||||
const payload = {
|
||||
id: encryptedUserId,
|
||||
email: encryptedEmail,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, NEXTAUTH_SECRET, {
|
||||
return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
};
|
||||
|
||||
export const createEmailToken = (email: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, NEXTAUTH_SECRET);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
|
||||
};
|
||||
|
||||
export const getEmailFromEmailToken = (token: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as JwtPayload;
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
const decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
|
||||
return decryptedEmail;
|
||||
} catch {
|
||||
// If decryption fails, return the original email (for older tokens)
|
||||
return payload.email;
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
email: string;
|
||||
};
|
||||
return decryptWithFallback(payload.email, ENCRYPTION_KEY);
|
||||
};
|
||||
|
||||
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedInviteId = symmetricEncrypt(inviteId, ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, NEXTAUTH_SECRET, options);
|
||||
const encryptedInviteId = symmetricEncrypt(inviteId, env.ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, env.NEXTAUTH_SECRET, options);
|
||||
};
|
||||
|
||||
export const verifyTokenForLinkSurvey = (token: string, surveyId: string): string | null => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let payload: JwtPayload & { email: string; surveyId?: string };
|
||||
|
||||
// Try primary method first (consistent secret)
|
||||
const { email } = jwt.verify(token, env.NEXTAUTH_SECRET + surveyId) as JwtPayload;
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
email: string;
|
||||
surveyId: string;
|
||||
};
|
||||
} catch (primaryError) {
|
||||
logger.error(primaryError, "Token verification failed with primary method");
|
||||
|
||||
// Fallback to legacy method (surveyId-based secret)
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET + surveyId, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
email: string;
|
||||
};
|
||||
} catch (legacyError) {
|
||||
logger.error(legacyError, "Token verification failed with legacy method");
|
||||
throw new Error("Invalid token");
|
||||
// Try to decrypt first (for newer tokens)
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
const decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
|
||||
return decryptedEmail;
|
||||
} catch {
|
||||
// If decryption fails, return the original email (for older tokens)
|
||||
return email;
|
||||
}
|
||||
|
||||
// Verify the surveyId matches if present in payload (new format)
|
||||
if (payload.surveyId && payload.surveyId !== surveyId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { email } = payload;
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decrypt email with fallback to plain text
|
||||
if (!ENCRYPTION_KEY) {
|
||||
return email; // Return as-is if encryption key not set
|
||||
}
|
||||
|
||||
return decryptWithFallback(email, ENCRYPTION_KEY);
|
||||
} catch (error) {
|
||||
logger.error(error, "Survey link token verification failed");
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get user email for legacy verification
|
||||
const getUserEmailForLegacyVerification = async (
|
||||
token: string,
|
||||
userId?: string
|
||||
): Promise<{ userId: string; userEmail: string }> => {
|
||||
if (!userId) {
|
||||
const decoded = jwt.decode(token);
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
// First decode to get the ID
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
|
||||
// Validate decoded token structure before using it
|
||||
if (
|
||||
!decoded ||
|
||||
typeof decoded !== "object" ||
|
||||
!decoded.id ||
|
||||
typeof decoded.id !== "string" ||
|
||||
decoded.id.trim() === ""
|
||||
) {
|
||||
logger.error("Invalid token: missing or invalid user ID");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
userId = decoded.id;
|
||||
if (!payload) {
|
||||
throw new Error("Token is invalid");
|
||||
}
|
||||
|
||||
const decryptedId = decryptWithFallback(userId, ENCRYPTION_KEY);
|
||||
|
||||
// Validate decrypted ID before database query
|
||||
if (!decryptedId || typeof decryptedId !== "string" || decryptedId.trim() === "") {
|
||||
logger.error("Invalid token: missing or invalid user ID");
|
||||
throw new Error("Invalid token");
|
||||
const { id } = payload;
|
||||
if (!id) {
|
||||
throw new Error("Token missing required field: id");
|
||||
}
|
||||
|
||||
// Try to decrypt the ID (for newer tokens), if it fails use the ID as-is (for older tokens)
|
||||
let decryptedId: string;
|
||||
try {
|
||||
decryptedId = symmetricDecrypt(id, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedId = id;
|
||||
}
|
||||
|
||||
// If no email provided, look up the user
|
||||
const foundUser = await prisma.user.findUnique({
|
||||
where: { id: decryptedId },
|
||||
});
|
||||
|
||||
if (!foundUser) {
|
||||
const errorMessage = "User not found";
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
return { userId: decryptedId, userEmail: foundUser.email };
|
||||
};
|
||||
const userEmail = foundUser.email;
|
||||
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
let payload: JwtPayload & { id: string };
|
||||
let userData: { userId: string; userEmail: string } | null = null;
|
||||
|
||||
// Try new method first, with smart fallback to legacy
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
id: string;
|
||||
};
|
||||
} catch (newMethodError) {
|
||||
logger.error(newMethodError, "Token verification failed with new method");
|
||||
|
||||
// Get user email for legacy verification
|
||||
userData = await getUserEmailForLegacyVerification(token);
|
||||
|
||||
// Try legacy verification with email-based secret
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET + userData.userEmail, {
|
||||
algorithms: ["HS256"],
|
||||
}) as JwtPayload & {
|
||||
id: string;
|
||||
};
|
||||
} catch (legacyMethodError) {
|
||||
logger.error(legacyMethodError, "Token verification failed with legacy method");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload?.id) {
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
// Get user email if we don't have it yet
|
||||
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
|
||||
|
||||
return { id: userData.userId, email: userData.userEmail };
|
||||
return { id: decryptedId, email: userEmail };
|
||||
};
|
||||
|
||||
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
inviteId: string;
|
||||
email: string;
|
||||
};
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
|
||||
const { inviteId: encryptedInviteId, email: encryptedEmail } = payload;
|
||||
const { inviteId, email } = payload;
|
||||
|
||||
if (!encryptedInviteId || !encryptedEmail) {
|
||||
throw new Error("Invalid token");
|
||||
let decryptedInviteId: string;
|
||||
let decryptedEmail: string;
|
||||
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
decryptedInviteId = symmetricDecrypt(inviteId, env.ENCRYPTION_KEY);
|
||||
decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
// If decryption fails, use original values (for older tokens)
|
||||
decryptedInviteId = inviteId;
|
||||
decryptedEmail = email;
|
||||
}
|
||||
|
||||
// Decrypt both fields with fallback to original values
|
||||
const decryptedInviteId = decryptWithFallback(encryptedInviteId, ENCRYPTION_KEY);
|
||||
const decryptedEmail = decryptWithFallback(encryptedEmail, ENCRYPTION_KEY);
|
||||
|
||||
return {
|
||||
inviteId: decryptedInviteId,
|
||||
email: decryptedEmail,
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { RefObject, useEffect } from "react";
|
||||
|
||||
// Helper function to check if a value is a DOM element with contains method
|
||||
const isDOMElement = (element: unknown): element is HTMLElement => {
|
||||
return element instanceof HTMLElement;
|
||||
};
|
||||
|
||||
// Improved version of https://usehooks.com/useOnClickOutside/
|
||||
export const useClickOutside = (
|
||||
ref: RefObject<HTMLElement | HTMLDivElement | null>,
|
||||
@@ -18,14 +13,14 @@ export const useClickOutside = (
|
||||
// Do nothing if `mousedown` or `touchstart` started inside ref element
|
||||
if (startedInside || !startedWhenMounted) return;
|
||||
// Do nothing if clicking ref's element or descendent elements
|
||||
if (!isDOMElement(ref.current) || ref.current.contains(event.target as Node)) return;
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) return;
|
||||
|
||||
handler(event);
|
||||
};
|
||||
|
||||
const validateEventStart = (event: MouseEvent | TouchEvent) => {
|
||||
startedWhenMounted = isDOMElement(ref.current);
|
||||
startedInside = isDOMElement(ref.current) && ref.current.contains(event.target as Node);
|
||||
startedWhenMounted = ref.current !== null;
|
||||
startedInside = ref.current !== null && ref.current.contains(event.target as Node);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", validateEventStart);
|
||||
|
||||
@@ -262,9 +262,7 @@
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
"metadata": "Metadaten",
|
||||
"minimum": "Minimum",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
|
||||
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
|
||||
"mobile_overlay_text": "Formbricks ist für Geräte mit kleineren Auflösungen nicht verfügbar.",
|
||||
"move_down": "Nach unten bewegen",
|
||||
"move_up": "Nach oben bewegen",
|
||||
"multiple_languages": "Mehrsprachigkeit",
|
||||
@@ -279,7 +277,6 @@
|
||||
"no_result_found": "Kein Ergebnis gefunden",
|
||||
"no_results": "Keine Ergebnisse",
|
||||
"no_surveys_found": "Keine Umfragen gefunden.",
|
||||
"none_of_the_above": "Keine der oben genannten Optionen",
|
||||
"not_authenticated": "Du bist nicht authentifiziert, um diese Aktion durchzuführen.",
|
||||
"not_authorized": "Nicht berechtigt",
|
||||
"not_connected": "Nicht verbunden",
|
||||
@@ -1204,12 +1201,12 @@
|
||||
"add_description": "Beschreibung hinzufügen",
|
||||
"add_ending": "Abschluss hinzufügen",
|
||||
"add_ending_below": "Abschluss unten hinzufügen",
|
||||
"add_fallback_placeholder": "Platzhalter hinzufügen, falls kein Wert zur Verfügung steht.",
|
||||
"add_fallback": "Hinzufügen",
|
||||
"add_fallback_placeholder": "Hinzufügen eines Platzhalters, der angezeigt wird, wenn die Frage übersprungen wird:",
|
||||
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
|
||||
"add_highlight_border": "Rahmen hinzufügen",
|
||||
"add_highlight_border_description": "Füge deiner Umfragekarte einen äußeren Rahmen hinzu.",
|
||||
"add_logic": "Logik hinzufügen",
|
||||
"add_none_of_the_above": "Füge \"Keine der oben genannten Optionen\" hinzu",
|
||||
"add_option": "Option hinzufügen",
|
||||
"add_other": "Anderes hinzufügen",
|
||||
"add_photo_or_video": "Foto oder Video hinzufügen",
|
||||
@@ -1242,7 +1239,6 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
|
||||
"back_button_label": "Zurück\"- Button ",
|
||||
"background_styling": "Hintergründe",
|
||||
"bold": "Fett",
|
||||
"brand_color": "Markenfarbe",
|
||||
"brightness": "Helligkeit",
|
||||
"button_label": "Beschriftung",
|
||||
@@ -1315,6 +1311,7 @@
|
||||
"days_before_showing_this_survey_again": "Tage, bevor diese Umfrage erneut angezeigt wird.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Entscheide, wie oft Leute diese Umfrage beantworten können.",
|
||||
"delete_choice": "Auswahl löschen",
|
||||
"description": "Beschreibung",
|
||||
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
|
||||
"display_number_of_responses_for_survey": "Anzahl der Antworten für Umfrage anzeigen",
|
||||
@@ -1325,7 +1322,6 @@
|
||||
"does_not_include_all_of": "Enthält nicht alle von",
|
||||
"does_not_include_one_of": "Enthält nicht eines von",
|
||||
"does_not_start_with": "Fängt nicht an mit",
|
||||
"edit_link": "Bearbeitungslink",
|
||||
"edit_recall": "Erinnerung bearbeiten",
|
||||
"edit_translations": "{lang} -Übersetzungen bearbeiten",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
|
||||
@@ -1336,13 +1332,13 @@
|
||||
"ending_card_used_in_logic": "Diese Abschlusskarte wird in der Logik der Frage {questionIndex} verwendet.",
|
||||
"ending_used_in_quota": "Dieses Ende wird in der \"{quotaName}\" Quote verwendet",
|
||||
"ends_with": "endet mit",
|
||||
"enter_fallback_value": "Ersatzwert eingeben",
|
||||
"equals": "Gleich",
|
||||
"equals_one_of": "Entspricht einem von",
|
||||
"error_publishing_survey": "Beim Veröffentlichen der Umfrage ist ein Fehler aufgetreten.",
|
||||
"error_saving_changes": "Fehler beim Speichern der Änderungen",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)",
|
||||
"everyone": "Jeder",
|
||||
"fallback_for": "Ersatz für",
|
||||
"fallback_missing": "Fehlender Fallback",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Verstecktes Feld \"{fieldId}\" wird in der \"{quotaName}\" Quote verwendet",
|
||||
@@ -1399,9 +1395,6 @@
|
||||
"four_points": "4 Punkte",
|
||||
"heading": "Überschrift",
|
||||
"hidden_field_added_successfully": "Verstecktes Feld erfolgreich hinzugefügt",
|
||||
"hidden_field_used_in_recall": "Verstecktes Feld \"{hiddenField}\" wird in Frage {questionIndex} abgerufen.",
|
||||
"hidden_field_used_in_recall_ending_card": "Verstecktes Feld \"{hiddenField}\" wird in der Abschlusskarte abgerufen.",
|
||||
"hidden_field_used_in_recall_welcome": "Verstecktes Feld \"{hiddenField}\" wird in der Willkommenskarte abgerufen.",
|
||||
"hide_advanced_settings": "Erweiterte Einstellungen ausblenden",
|
||||
"hide_back_button": "'Zurück'-Button ausblenden",
|
||||
"hide_back_button_description": "Den Zurück-Button in der Umfrage nicht anzeigen",
|
||||
@@ -1420,7 +1413,6 @@
|
||||
"inner_text": "Innerer Text",
|
||||
"input_border_color": "Randfarbe des Eingabefelds",
|
||||
"input_color": "Farbe des Eingabefelds",
|
||||
"insert_link": "Link einfügen",
|
||||
"invalid_targeting": "Ungültiges Targeting: Bitte überprüfe deine Zielgruppenfilter",
|
||||
"invalid_video_url_warning": "Bitte gib eine gültige YouTube-, Vimeo- oder Loom-URL ein. Andere Video-Plattformen werden derzeit nicht unterstützt.",
|
||||
"invalid_youtube_url": "Ungültige YouTube-URL",
|
||||
@@ -1438,7 +1430,6 @@
|
||||
"is_set": "Ist festgelegt",
|
||||
"is_skipped": "Wird übersprungen",
|
||||
"is_submitted": "Wird eingereicht",
|
||||
"italic": "Kursiv",
|
||||
"jump_to_question": "Zur Frage springen",
|
||||
"keep_current_order": "Bestehende Anordnung beibehalten",
|
||||
"keep_showing_while_conditions_match": "Zeige weiter, solange die Bedingungen übereinstimmen",
|
||||
@@ -1465,7 +1456,6 @@
|
||||
"no_images_found_for": "Keine Bilder gefunden für ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Keine Sprachen gefunden. Füge die erste hinzu, um loszulegen.",
|
||||
"no_option_found": "Keine Option gefunden",
|
||||
"no_recall_items_found": "Keine Erinnerungsstücke gefunden",
|
||||
"no_variables_yet_add_first_one_below": "Noch keine Variablen. Füge die erste hinzu.",
|
||||
"number": "Nummer",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Sobald die Standardsprache für diese Umfrage festgelegt ist, kann sie nur geändert werden, indem die Mehrsprachigkeitsoption deaktiviert und alle Übersetzungen gelöscht werden.",
|
||||
@@ -1485,7 +1475,6 @@
|
||||
"pin_can_only_contain_numbers": "PIN darf nur Zahlen enthalten.",
|
||||
"pin_must_be_a_four_digit_number": "Die PIN muss eine vierstellige Zahl sein.",
|
||||
"please_enter_a_file_extension": "Bitte gib eine Dateierweiterung ein.",
|
||||
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein (z. B. https://beispiel.de)",
|
||||
"please_set_a_survey_trigger": "Bitte richte einen Umfrage-Trigger ein",
|
||||
"please_specify": "Bitte angeben",
|
||||
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
|
||||
@@ -1500,8 +1489,6 @@
|
||||
"question_id_updated": "Frage-ID aktualisiert",
|
||||
"question_used_in_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
|
||||
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
|
||||
"question_used_in_recall": "Diese Frage wird in Frage {questionIndex} abgerufen.",
|
||||
"question_used_in_recall_ending_card": "Diese Frage wird in der Abschlusskarte abgerufen.",
|
||||
"quotas": {
|
||||
"add_quota": "Quote hinzufügen",
|
||||
"change_quota_for_public_survey": "Quote für öffentliche Umfrage ändern?",
|
||||
@@ -1536,8 +1523,6 @@
|
||||
"randomize_all": "Alle Optionen zufällig anordnen",
|
||||
"randomize_all_except_last": "Alle Optionen zufällig anordnen außer der letzten",
|
||||
"range": "Reichweite",
|
||||
"recall_data": "Daten abrufen",
|
||||
"recall_information_from": "Information abrufen von ...",
|
||||
"recontact_options": "Optionen zur erneuten Kontaktaufnahme",
|
||||
"redirect_thank_you_card": "Weiterleitung anlegen",
|
||||
"redirect_to_url": "Zu URL weiterleiten",
|
||||
@@ -1615,7 +1600,6 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird...",
|
||||
"try_lollipop_or_mountain": "Versuch 'Lolli' oder 'Berge'...",
|
||||
"type_field_id": "Feld-ID eingeben",
|
||||
"underline": "Unterstreichen",
|
||||
"unlock_targeting_description": "Spezifische Nutzergruppen basierend auf Attributen oder Geräteinformationen ansprechen",
|
||||
"unlock_targeting_title": "Targeting mit einem höheren Plan freischalten",
|
||||
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
|
||||
@@ -1632,9 +1616,6 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
|
||||
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
|
||||
"variable_used_in_recall": "Variable \"{variable}\" wird in Frage {questionIndex} abgerufen.",
|
||||
"variable_used_in_recall_ending_card": "Variable \"{variable}\" wird in der Abschlusskarte abgerufen.",
|
||||
"variable_used_in_recall_welcome": "Variable \"{variable}\" wird in der Willkommenskarte abgerufen.",
|
||||
"verify_email_before_submission": "E-Mail vor dem Absenden überprüfen",
|
||||
"verify_email_before_submission_description": "Lass nur Leute mit einer echten E-Mail antworten.",
|
||||
"wait": "Warte",
|
||||
|
||||
@@ -262,9 +262,7 @@
|
||||
"membership_not_found": "Membership not found",
|
||||
"metadata": "Metadata",
|
||||
"minimum": "Minimum",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
|
||||
"mobile_overlay_surveys_look_good": "Don't worry – your surveys look great on every device and screen size!",
|
||||
"mobile_overlay_title": "Oops, tiny screen detected!",
|
||||
"mobile_overlay_text": "Formbricks is not available for devices with smaller resolutions.",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
@@ -279,7 +277,6 @@
|
||||
"no_result_found": "No result found",
|
||||
"no_results": "No results",
|
||||
"no_surveys_found": "No surveys found.",
|
||||
"none_of_the_above": "None of the above",
|
||||
"not_authenticated": "You are not authenticated to perform this action.",
|
||||
"not_authorized": "Not authorized",
|
||||
"not_connected": "Not Connected",
|
||||
@@ -1204,12 +1201,12 @@
|
||||
"add_description": "Add description",
|
||||
"add_ending": "Add ending",
|
||||
"add_ending_below": "Add ending below",
|
||||
"add_fallback_placeholder": "Add a placeholder to show if there is no value to recall.",
|
||||
"add_fallback": "Add",
|
||||
"add_fallback_placeholder": "Add a placeholder to show if the question gets skipped:",
|
||||
"add_hidden_field_id": "Add hidden field ID",
|
||||
"add_highlight_border": "Add highlight border",
|
||||
"add_highlight_border_description": "Add an outer border to your survey card.",
|
||||
"add_logic": "Add logic",
|
||||
"add_none_of_the_above": "Add \"None of the Above\"",
|
||||
"add_option": "Add option",
|
||||
"add_other": "Add \"Other\"",
|
||||
"add_photo_or_video": "Add photo or video",
|
||||
@@ -1242,7 +1239,6 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
|
||||
"back_button_label": "\"Back\" Button Label",
|
||||
"background_styling": "Background Styling",
|
||||
"bold": "Bold",
|
||||
"brand_color": "Brand color",
|
||||
"brightness": "Brightness",
|
||||
"button_label": "Button Label",
|
||||
@@ -1303,8 +1299,8 @@
|
||||
"contains": "Contains",
|
||||
"continue_to_settings": "Continue to Settings",
|
||||
"control_which_file_types_can_be_uploaded": "Control which file types can be uploaded.",
|
||||
"convert_to_multiple_choice": "Convert to Multi-select",
|
||||
"convert_to_single_choice": "Convert to Single-select",
|
||||
"convert_to_multiple_choice": "Convert to Multiple Choice",
|
||||
"convert_to_single_choice": "Convert to Single Choice",
|
||||
"country": "Country",
|
||||
"create_group": "Create group",
|
||||
"create_your_own_survey": "Create your own survey",
|
||||
@@ -1315,6 +1311,7 @@
|
||||
"days_before_showing_this_survey_again": "days before showing this survey again.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decide how often people can answer this survey.",
|
||||
"delete_choice": "Delete choice",
|
||||
"description": "Description",
|
||||
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
|
||||
"display_number_of_responses_for_survey": "Display number of responses for survey",
|
||||
@@ -1325,7 +1322,6 @@
|
||||
"does_not_include_all_of": "Does not include all of",
|
||||
"does_not_include_one_of": "Does not include one of",
|
||||
"does_not_start_with": "Does not start with",
|
||||
"edit_link": "Edit link",
|
||||
"edit_recall": "Edit Recall",
|
||||
"edit_translations": "Edit {lang} translations",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
|
||||
@@ -1336,13 +1332,13 @@
|
||||
"ending_card_used_in_logic": "This ending card is used in logic of question {questionIndex}.",
|
||||
"ending_used_in_quota": "This ending is being used in \"{quotaName}\" quota",
|
||||
"ends_with": "Ends with",
|
||||
"enter_fallback_value": "Enter fallback value",
|
||||
"equals": "Equals",
|
||||
"equals_one_of": "Equals one of",
|
||||
"error_publishing_survey": "An error occured while publishing the survey.",
|
||||
"error_saving_changes": "Error saving changes",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)",
|
||||
"everyone": "Everyone",
|
||||
"fallback_for": "Fallback for ",
|
||||
"fallback_missing": "Fallback missing",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Hidden field \"{fieldId}\" is being used in \"{quotaName}\" quota",
|
||||
@@ -1399,9 +1395,6 @@
|
||||
"four_points": "4 points",
|
||||
"heading": "Heading",
|
||||
"hidden_field_added_successfully": "Hidden field added successfully",
|
||||
"hidden_field_used_in_recall": "Hidden field \"{hiddenField}\" is being recalled in question {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Hidden field \"{hiddenField}\" is being recalled in Ending Card",
|
||||
"hidden_field_used_in_recall_welcome": "Hidden field \"{hiddenField}\" is being recalled in Welcome card.",
|
||||
"hide_advanced_settings": "Hide advanced settings",
|
||||
"hide_back_button": "Hide 'Back' button",
|
||||
"hide_back_button_description": "Do not display the back button in the survey",
|
||||
@@ -1420,7 +1413,6 @@
|
||||
"inner_text": "Inner Text",
|
||||
"input_border_color": "Input border color",
|
||||
"input_color": "Input color",
|
||||
"insert_link": "Insert link",
|
||||
"invalid_targeting": "Invalid targeting: Please check your audience filters",
|
||||
"invalid_video_url_warning": "Please enter a valid YouTube, Vimeo, or Loom URL. We currently do not support other video hosting providers.",
|
||||
"invalid_youtube_url": "Invalid YouTube URL",
|
||||
@@ -1438,7 +1430,6 @@
|
||||
"is_set": "Is set",
|
||||
"is_skipped": "Is skipped",
|
||||
"is_submitted": "Is submitted",
|
||||
"italic": "Italic",
|
||||
"jump_to_question": "Jump to question",
|
||||
"keep_current_order": "Keep current order",
|
||||
"keep_showing_while_conditions_match": "Keep showing while conditions match",
|
||||
@@ -1465,7 +1456,6 @@
|
||||
"no_images_found_for": "No images found for ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.",
|
||||
"no_option_found": "No option found",
|
||||
"no_recall_items_found": "No recall items found ",
|
||||
"no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.",
|
||||
"number": "Number",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Once set, the default language for this survey can only be changed by disabling the multi-language option and deleting all translations.",
|
||||
@@ -1485,7 +1475,6 @@
|
||||
"pin_can_only_contain_numbers": "PIN can only contain numbers.",
|
||||
"pin_must_be_a_four_digit_number": "PIN must be a four digit number.",
|
||||
"please_enter_a_file_extension": "Please enter a file extension.",
|
||||
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)",
|
||||
"please_set_a_survey_trigger": "Please set a survey trigger",
|
||||
"please_specify": "Please specify",
|
||||
"prevent_double_submission": "Prevent double submission",
|
||||
@@ -1500,8 +1489,6 @@
|
||||
"question_id_updated": "Question ID updated",
|
||||
"question_used_in_logic": "This question is used in logic of question {questionIndex}.",
|
||||
"question_used_in_quota": "This question is being used in \"{quotaName}\" quota",
|
||||
"question_used_in_recall": "This question is being recalled in question {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "This question is being recalled in Ending Card",
|
||||
"quotas": {
|
||||
"add_quota": "Add quota",
|
||||
"change_quota_for_public_survey": "Change quota for public survey?",
|
||||
@@ -1536,8 +1523,6 @@
|
||||
"randomize_all": "Randomize all",
|
||||
"randomize_all_except_last": "Randomize all except last",
|
||||
"range": "Range",
|
||||
"recall_data": "Recall data",
|
||||
"recall_information_from": "Recall information from ...",
|
||||
"recontact_options": "Recontact Options",
|
||||
"redirect_thank_you_card": "Redirect thank you card",
|
||||
"redirect_to_url": "Redirect to Url",
|
||||
@@ -1615,7 +1600,6 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Trigger survey when one of the actions is fired...",
|
||||
"try_lollipop_or_mountain": "Try 'lollipop' or 'mountain'...",
|
||||
"type_field_id": "Type field id",
|
||||
"underline": "Underline",
|
||||
"unlock_targeting_description": "Target specific user groups based on attributes or device information",
|
||||
"unlock_targeting_title": "Unlock targeting with a higher plan",
|
||||
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
|
||||
@@ -1632,9 +1616,6 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.",
|
||||
"variable_name_must_start_with_a_letter": "Variable name must start with a letter.",
|
||||
"variable_used_in_recall": "Variable \"{variable}\" is being recalled in question {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variable {variable} is being recalled in Ending Card",
|
||||
"variable_used_in_recall_welcome": "Variable \"{variable}\" is being recalled in Welcome Card.",
|
||||
"verify_email_before_submission": "Verify email before submission",
|
||||
"verify_email_before_submission_description": "Only let people with a real email respond.",
|
||||
"wait": "Wait",
|
||||
|
||||
@@ -262,9 +262,7 @@
|
||||
"membership_not_found": "Abonnement non trouvé",
|
||||
"metadata": "Métadonnées",
|
||||
"minimum": "Min",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
|
||||
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas – tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
|
||||
"mobile_overlay_title": "Oups, écran minuscule détecté!",
|
||||
"mobile_overlay_text": "Formbricks n'est pas disponible pour les appareils avec des résolutions plus petites.",
|
||||
"move_down": "Déplacer vers le bas",
|
||||
"move_up": "Déplacer vers le haut",
|
||||
"multiple_languages": "Plusieurs langues",
|
||||
@@ -279,7 +277,6 @@
|
||||
"no_result_found": "Aucun résultat trouvé",
|
||||
"no_results": "Aucun résultat",
|
||||
"no_surveys_found": "Aucun sondage trouvé.",
|
||||
"none_of_the_above": "Aucun des éléments ci-dessus",
|
||||
"not_authenticated": "Vous n'êtes pas authentifié pour effectuer cette action.",
|
||||
"not_authorized": "Non autorisé",
|
||||
"not_connected": "Non connecté",
|
||||
@@ -1204,12 +1201,12 @@
|
||||
"add_description": "Ajouter une description",
|
||||
"add_ending": "Ajouter une fin",
|
||||
"add_ending_below": "Ajouter une fin ci-dessous",
|
||||
"add_fallback_placeholder": "Ajouter un espace réservé à afficher s'il n'y a pas de valeur à rappeler.",
|
||||
"add_fallback": "Ajouter",
|
||||
"add_fallback_placeholder": "Ajouter un espace réservé pour montrer si la question est ignorée :",
|
||||
"add_hidden_field_id": "Ajouter un champ caché ID",
|
||||
"add_highlight_border": "Ajouter une bordure de surlignage",
|
||||
"add_highlight_border_description": "Ajoutez une bordure extérieure à votre carte d'enquête.",
|
||||
"add_logic": "Ajouter de la logique",
|
||||
"add_none_of_the_above": "Ajouter \"Aucun des éléments ci-dessus\"",
|
||||
"add_option": "Ajouter une option",
|
||||
"add_other": "Ajouter \"Autre",
|
||||
"add_photo_or_video": "Ajouter une photo ou une vidéo",
|
||||
@@ -1242,7 +1239,6 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
|
||||
"back_button_label": "Label du bouton \"Retour''",
|
||||
"background_styling": "Style de fond",
|
||||
"bold": "Gras",
|
||||
"brand_color": "Couleur de marque",
|
||||
"brightness": "Luminosité",
|
||||
"button_label": "Label du bouton",
|
||||
@@ -1315,6 +1311,7 @@
|
||||
"days_before_showing_this_survey_again": "jours avant de montrer à nouveau cette enquête.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Décidez à quelle fréquence les gens peuvent répondre à cette enquête.",
|
||||
"delete_choice": "Supprimer l'option",
|
||||
"description": "Description",
|
||||
"disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.",
|
||||
"display_number_of_responses_for_survey": "Afficher le nombre de réponses pour l'enquête",
|
||||
@@ -1325,7 +1322,6 @@
|
||||
"does_not_include_all_of": "n'inclut pas tout",
|
||||
"does_not_include_one_of": "n'inclut pas un de",
|
||||
"does_not_start_with": "Ne commence pas par",
|
||||
"edit_link": "Modifier le lien",
|
||||
"edit_recall": "Modifier le rappel",
|
||||
"edit_translations": "Modifier les traductions {lang}",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
|
||||
@@ -1336,13 +1332,13 @@
|
||||
"ending_card_used_in_logic": "Cette carte de fin est utilisée dans la logique de la question '{'questionIndex'}'.",
|
||||
"ending_used_in_quota": "Cette fin est utilisée dans le quota \"{quotaName}\"",
|
||||
"ends_with": "Se termine par",
|
||||
"enter_fallback_value": "Saisir une valeur de secours",
|
||||
"equals": "Égal",
|
||||
"equals_one_of": "Égal à l'un de",
|
||||
"error_publishing_survey": "Une erreur est survenue lors de la publication de l'enquête.",
|
||||
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)",
|
||||
"everyone": "Tout le monde",
|
||||
"fallback_for": "Solution de repli pour ",
|
||||
"fallback_missing": "Fallback manquant",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Le champ masqué \"{fieldId}\" est utilisé dans le quota \"{quotaName}\"",
|
||||
@@ -1399,9 +1395,6 @@
|
||||
"four_points": "4 points",
|
||||
"heading": "En-tête",
|
||||
"hidden_field_added_successfully": "Champ caché ajouté avec succès",
|
||||
"hidden_field_used_in_recall": "Le champ caché \"{hiddenField}\" est rappelé dans la question {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de fin.",
|
||||
"hidden_field_used_in_recall_welcome": "Le champ caché \"{hiddenField}\" est rappelé dans la carte de bienvenue.",
|
||||
"hide_advanced_settings": "Cacher les paramètres avancés",
|
||||
"hide_back_button": "Masquer le bouton 'Retour'",
|
||||
"hide_back_button_description": "Ne pas afficher le bouton retour dans l'enquête",
|
||||
@@ -1420,7 +1413,6 @@
|
||||
"inner_text": "Texte interne",
|
||||
"input_border_color": "Couleur de bordure d'entrée",
|
||||
"input_color": "Couleur d'entrée",
|
||||
"insert_link": "Insérer un lien",
|
||||
"invalid_targeting": "Ciblage invalide : Veuillez vérifier vos filtres d'audience",
|
||||
"invalid_video_url_warning": "Merci d'entrer une URL YouTube, Vimeo ou Loom valide. Les autres plateformes vidéo ne sont pas encore supportées.",
|
||||
"invalid_youtube_url": "URL YouTube invalide",
|
||||
@@ -1438,7 +1430,6 @@
|
||||
"is_set": "Est défini",
|
||||
"is_skipped": "Est ignoré",
|
||||
"is_submitted": "Est soumis",
|
||||
"italic": "Italique",
|
||||
"jump_to_question": "Passer à la question",
|
||||
"keep_current_order": "Conserver la commande actuelle",
|
||||
"keep_showing_while_conditions_match": "Continuer à afficher tant que les conditions correspondent",
|
||||
@@ -1465,7 +1456,6 @@
|
||||
"no_images_found_for": "Aucune image trouvée pour ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Aucune langue trouvée. Ajoutez la première pour commencer.",
|
||||
"no_option_found": "Aucune option trouvée",
|
||||
"no_recall_items_found": "Aucun élément de rappel trouvé",
|
||||
"no_variables_yet_add_first_one_below": "Aucune variable pour le moment. Ajoutez la première ci-dessous.",
|
||||
"number": "Numéro",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Une fois défini, la langue par défaut de cette enquête ne peut être changée qu'en désactivant l'option multilingue et en supprimant toutes les traductions.",
|
||||
@@ -1485,7 +1475,6 @@
|
||||
"pin_can_only_contain_numbers": "Le code PIN ne peut contenir que des chiffres.",
|
||||
"pin_must_be_a_four_digit_number": "Le code PIN doit être un numéro à quatre chiffres.",
|
||||
"please_enter_a_file_extension": "Veuillez entrer une extension de fichier.",
|
||||
"please_enter_a_valid_url": "Veuillez entrer une URL valide (par exemple, https://example.com)",
|
||||
"please_set_a_survey_trigger": "Veuillez définir un déclencheur d'enquête.",
|
||||
"please_specify": "Veuillez préciser",
|
||||
"prevent_double_submission": "Empêcher la double soumission",
|
||||
@@ -1500,8 +1489,6 @@
|
||||
"question_id_updated": "ID de la question mis à jour",
|
||||
"question_used_in_logic": "Cette question est utilisée dans la logique de la question '{'questionIndex'}'.",
|
||||
"question_used_in_quota": "Cette question est utilisée dans le quota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Cette question est rappelée dans la question {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Cette question est rappelée dans la carte de fin.",
|
||||
"quotas": {
|
||||
"add_quota": "Ajouter un quota",
|
||||
"change_quota_for_public_survey": "Changer le quota pour le sondage public ?",
|
||||
@@ -1536,8 +1523,6 @@
|
||||
"randomize_all": "Randomiser tout",
|
||||
"randomize_all_except_last": "Randomiser tout sauf le dernier",
|
||||
"range": "Plage",
|
||||
"recall_data": "Rappel des données",
|
||||
"recall_information_from": "Rappeler les informations de ...",
|
||||
"recontact_options": "Options de recontact",
|
||||
"redirect_thank_you_card": "Carte de remerciement de redirection",
|
||||
"redirect_to_url": "Rediriger vers l'URL",
|
||||
@@ -1615,7 +1600,6 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Déclencher l'enquête lorsqu'une des actions est déclenchée...",
|
||||
"try_lollipop_or_mountain": "Essayez 'sucette' ou 'montagne'...",
|
||||
"type_field_id": "Identifiant de champ de type",
|
||||
"underline": "Souligner",
|
||||
"unlock_targeting_description": "Cibler des groupes d'utilisateurs spécifiques en fonction des attributs ou des informations sur l'appareil",
|
||||
"unlock_targeting_title": "Débloquez le ciblage avec un plan supérieur.",
|
||||
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
|
||||
@@ -1632,9 +1616,6 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
|
||||
"variable_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
|
||||
"variable_used_in_recall": "La variable \"{variable}\" est rappelée dans la question {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "La variable {variable} est rappelée dans la carte de fin.",
|
||||
"variable_used_in_recall_welcome": "La variable \"{variable}\" est rappelée dans la carte de bienvenue.",
|
||||
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
|
||||
"verify_email_before_submission_description": "Ne laissez répondre que les personnes ayant une véritable adresse e-mail.",
|
||||
"wait": "Attendre",
|
||||
|
||||
@@ -262,9 +262,7 @@
|
||||
"membership_not_found": "メンバーシップが見つかりません",
|
||||
"metadata": "メタデータ",
|
||||
"minimum": "最小",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
|
||||
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
|
||||
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
|
||||
"mobile_overlay_text": "Formbricksは、解像度の小さいデバイスでは利用できません。",
|
||||
"move_down": "下に移動",
|
||||
"move_up": "上に移動",
|
||||
"multiple_languages": "多言語",
|
||||
@@ -279,7 +277,6 @@
|
||||
"no_result_found": "結果が見つかりません",
|
||||
"no_results": "結果なし",
|
||||
"no_surveys_found": "フォームが見つかりません。",
|
||||
"none_of_the_above": "いずれも該当しません",
|
||||
"not_authenticated": "このアクションを実行するための認証がされていません。",
|
||||
"not_authorized": "権限がありません",
|
||||
"not_connected": "未接続",
|
||||
@@ -1204,12 +1201,12 @@
|
||||
"add_description": "説明を追加",
|
||||
"add_ending": "終了を追加",
|
||||
"add_ending_below": "以下に終了を追加",
|
||||
"add_fallback": "追加",
|
||||
"add_fallback_placeholder": "質問がスキップされた場合に表示するプレースホルダーを追加:",
|
||||
"add_hidden_field_id": "非表示フィールドIDを追加",
|
||||
"add_highlight_border": "ハイライトボーダーを追加",
|
||||
"add_highlight_border_description": "フォームカードに外側のボーダーを追加します。",
|
||||
"add_logic": "ロジックを追加",
|
||||
"add_none_of_the_above": "\"いずれも該当しません\" を追加",
|
||||
"add_option": "オプションを追加",
|
||||
"add_other": "「その他」を追加",
|
||||
"add_photo_or_video": "写真または動画を追加",
|
||||
@@ -1242,7 +1239,6 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
|
||||
"back_button_label": "「戻る」ボタンのラベル",
|
||||
"background_styling": "背景のスタイル",
|
||||
"bold": "太字",
|
||||
"brand_color": "ブランドカラー",
|
||||
"brightness": "明るさ",
|
||||
"button_label": "ボタンのラベル",
|
||||
@@ -1315,6 +1311,7 @@
|
||||
"days_before_showing_this_survey_again": "日後にこのフォームを再度表示します。",
|
||||
"decide_how_often_people_can_answer_this_survey": "このフォームに人々が何回回答できるかを決定します。",
|
||||
"delete_choice": "選択肢を削除",
|
||||
"description": "説明",
|
||||
"disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示",
|
||||
"display_number_of_responses_for_survey": "フォームの回答数を表示",
|
||||
@@ -1325,7 +1322,6 @@
|
||||
"does_not_include_all_of": "のすべてを含まない",
|
||||
"does_not_include_one_of": "のいずれも含まない",
|
||||
"does_not_start_with": "で始まらない",
|
||||
"edit_link": "編集 リンク",
|
||||
"edit_recall": "リコールを編集",
|
||||
"edit_translations": "{lang} 翻訳を編集",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がフォームの途中でいつでも言語を切り替えられるようにします。",
|
||||
@@ -1336,13 +1332,13 @@
|
||||
"ending_card_used_in_logic": "この終了カードは質問 {questionIndex} のロジックで使用されています。",
|
||||
"ending_used_in_quota": "この 終了 は \"{quotaName}\" クォータ で使用されています",
|
||||
"ends_with": "で終わる",
|
||||
"enter_fallback_value": "フォールバック値を入力",
|
||||
"equals": "と等しい",
|
||||
"equals_one_of": "のいずれかと等しい",
|
||||
"error_publishing_survey": "フォームの公開中にエラーが発生しました。",
|
||||
"error_saving_changes": "変更の保存中にエラーが発生しました",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "回答を送信した後でも(例:フィードバックボックス)",
|
||||
"everyone": "全員",
|
||||
"fallback_for": "のフォールバック",
|
||||
"fallback_missing": "フォールバックがありません",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隠しフィールド \"{fieldId}\" は \"{quotaName}\" クォータ で使用されています",
|
||||
@@ -1399,9 +1395,6 @@
|
||||
"four_points": "4点",
|
||||
"heading": "見出し",
|
||||
"hidden_field_added_successfully": "非表示フィールドを正常に追加しました",
|
||||
"hidden_field_used_in_recall": "隠し フィールド \"{hiddenField}\" が 質問 {questionIndex} で 呼び出され て います 。",
|
||||
"hidden_field_used_in_recall_ending_card": "隠し フィールド \"{hiddenField}\" が エンディング カード で 呼び出され て います。",
|
||||
"hidden_field_used_in_recall_welcome": "隠し フィールド \"{hiddenField}\" が ウェルカム カード で 呼び出され て います。",
|
||||
"hide_advanced_settings": "詳細設定を非表示",
|
||||
"hide_back_button": "「戻る」ボタンを非表示",
|
||||
"hide_back_button_description": "フォームに「戻る」ボタンを表示しない",
|
||||
@@ -1420,7 +1413,6 @@
|
||||
"inner_text": "内部テキスト",
|
||||
"input_border_color": "入力の枠線の色",
|
||||
"input_color": "入力の色",
|
||||
"insert_link": "リンク を 挿入",
|
||||
"invalid_targeting": "無効なターゲティング: オーディエンスフィルターを確認してください",
|
||||
"invalid_video_url_warning": "有効なYouTube、Vimeo、またはLoomのURLを入力してください。現在、他の動画ホスティングプロバイダーはサポートしていません。",
|
||||
"invalid_youtube_url": "無効なYouTube URL",
|
||||
@@ -1438,7 +1430,6 @@
|
||||
"is_set": "設定されている",
|
||||
"is_skipped": "スキップ済み",
|
||||
"is_submitted": "送信済み",
|
||||
"italic": "イタリック",
|
||||
"jump_to_question": "質問にジャンプ",
|
||||
"keep_current_order": "現在の順序を維持",
|
||||
"keep_showing_while_conditions_match": "条件が一致する間、表示し続ける",
|
||||
@@ -1465,7 +1456,6 @@
|
||||
"no_images_found_for": "''{query}'' の画像が見つかりません",
|
||||
"no_languages_found_add_first_one_to_get_started": "言語が見つかりません。始めるには、最初のものを追加してください。",
|
||||
"no_option_found": "オプションが見つかりません",
|
||||
"no_recall_items_found": "リコールアイテムが見つかりません ",
|
||||
"no_variables_yet_add_first_one_below": "まだ変数がありません。以下で最初のものを追加してください。",
|
||||
"number": "数値",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "一度設定すると、このフォームのデフォルト言語は、多言語オプションを無効にしてすべての翻訳を削除することによってのみ変更できます。",
|
||||
@@ -1485,7 +1475,6 @@
|
||||
"pin_can_only_contain_numbers": "PINは数字のみでなければなりません。",
|
||||
"pin_must_be_a_four_digit_number": "PINは4桁の数字でなければなりません。",
|
||||
"please_enter_a_file_extension": "ファイル拡張子を入力してください。",
|
||||
"please_enter_a_valid_url": "有効な URL を入力してください (例:https://example.com)",
|
||||
"please_set_a_survey_trigger": "フォームのトリガーを設定してください",
|
||||
"please_specify": "具体的に指定してください",
|
||||
"prevent_double_submission": "二重送信を防ぐ",
|
||||
@@ -1500,8 +1489,6 @@
|
||||
"question_id_updated": "質問IDを更新しました",
|
||||
"question_used_in_logic": "この質問は質問 {questionIndex} のロジックで使用されています。",
|
||||
"question_used_in_quota": "この 質問 は \"{quotaName}\" の クオータ に使用されています",
|
||||
"question_used_in_recall": "この 質問 は 質問 {questionIndex} で 呼び出され て います 。",
|
||||
"question_used_in_recall_ending_card": "この 質問 は エンディング カード で 呼び出され て います。",
|
||||
"quotas": {
|
||||
"add_quota": "クォータを追加",
|
||||
"change_quota_for_public_survey": "パブリック フォームのクォータを変更しますか?",
|
||||
@@ -1536,8 +1523,6 @@
|
||||
"randomize_all": "すべてをランダム化",
|
||||
"randomize_all_except_last": "最後を除くすべてをランダム化",
|
||||
"range": "範囲",
|
||||
"recall_data": "データを呼び出す",
|
||||
"recall_information_from": "... からの情報を呼び戻す",
|
||||
"recontact_options": "再接触オプション",
|
||||
"redirect_thank_you_card": "サンクスクカードをリダイレクト",
|
||||
"redirect_to_url": "URLにリダイレクト",
|
||||
@@ -1615,7 +1600,6 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "以下のアクションのいずれかが発火したときにフォームをトリガーします...",
|
||||
"try_lollipop_or_mountain": "「lollipop」や「mountain」を試してみてください...",
|
||||
"type_field_id": "フィールドIDを入力",
|
||||
"underline": "下線",
|
||||
"unlock_targeting_description": "属性またはデバイス情報に基づいて、特定のユーザーグループをターゲットにします",
|
||||
"unlock_targeting_title": "上位プランでターゲティングをアンロック",
|
||||
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
|
||||
@@ -1632,9 +1616,6 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
|
||||
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
|
||||
"variable_name_must_start_with_a_letter": "変数名はアルファベットで始まらなければなりません。",
|
||||
"variable_used_in_recall": "変数 \"{variable}\" が 質問 {questionIndex} で 呼び出され て います 。",
|
||||
"variable_used_in_recall_ending_card": "変数 {variable} が エンディング カード で 呼び出され て います。",
|
||||
"variable_used_in_recall_welcome": "変数 \"{variable}\" が ウェルカム カード で 呼び出され て います。",
|
||||
"verify_email_before_submission": "送信前にメールアドレスを認証",
|
||||
"verify_email_before_submission_description": "有効なメールアドレスを持つ人のみが回答できるようにする",
|
||||
"wait": "待つ",
|
||||
|
||||
@@ -262,9 +262,7 @@
|
||||
"membership_not_found": "Assinatura não encontrada",
|
||||
"metadata": "metadados",
|
||||
"minimum": "Mínimo",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
|
||||
"mobile_overlay_title": "Eita, tela pequena detectada!",
|
||||
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
|
||||
"move_down": "Descer",
|
||||
"move_up": "Subir",
|
||||
"multiple_languages": "Vários idiomas",
|
||||
@@ -279,7 +277,6 @@
|
||||
"no_result_found": "Nenhum resultado encontrado",
|
||||
"no_results": "Nenhum resultado",
|
||||
"no_surveys_found": "Não foram encontradas pesquisas.",
|
||||
"none_of_the_above": "Nenhuma das opções acima",
|
||||
"not_authenticated": "Você não está autenticado para realizar essa ação.",
|
||||
"not_authorized": "Não autorizado",
|
||||
"not_connected": "Desconectado",
|
||||
@@ -1204,12 +1201,12 @@
|
||||
"add_description": "Adicionar Descrição",
|
||||
"add_ending": "Adicionar final",
|
||||
"add_ending_below": "Adicione o final abaixo",
|
||||
"add_fallback": "Adicionar",
|
||||
"add_fallback_placeholder": "Adicionar um texto padrão para mostrar se a pergunta for ignorada:",
|
||||
"add_hidden_field_id": "Adicionar campo oculto ID",
|
||||
"add_highlight_border": "Adicionar borda de destaque",
|
||||
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de pesquisa.",
|
||||
"add_logic": "Adicionar lógica",
|
||||
"add_none_of_the_above": "Adicionar \"Nenhuma das opções acima\"",
|
||||
"add_option": "Adicionar opção",
|
||||
"add_other": "Adicionar \"Outro",
|
||||
"add_photo_or_video": "Adicionar foto ou video",
|
||||
@@ -1242,7 +1239,6 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
|
||||
"back_button_label": "Voltar",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"bold": "Negrito",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "brilho",
|
||||
"button_label": "Rótulo do Botão",
|
||||
@@ -1315,6 +1311,7 @@
|
||||
"days_before_showing_this_survey_again": "dias antes de mostrar essa pesquisa de novo.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a essa pesquisa.",
|
||||
"delete_choice": "Deletar opção",
|
||||
"description": "Descrição",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa",
|
||||
"display_number_of_responses_for_survey": "Mostrar número de respostas da pesquisa",
|
||||
@@ -1325,7 +1322,6 @@
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"edit_link": "Editar link",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções de {lang}",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
|
||||
@@ -1336,13 +1332,13 @@
|
||||
"ending_card_used_in_logic": "Esse cartão de encerramento é usado na lógica da pergunta {questionIndex}.",
|
||||
"ending_used_in_quota": "Este final está sendo usado na cota \"{quotaName}\"",
|
||||
"ends_with": "Termina com",
|
||||
"enter_fallback_value": "Insira o valor de fallback",
|
||||
"equals": "Igual",
|
||||
"equals_one_of": "É igual a um de",
|
||||
"error_publishing_survey": "Ocorreu um erro ao publicar a pesquisa.",
|
||||
"error_saving_changes": "Erro ao salvar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)",
|
||||
"everyone": "Todo mundo",
|
||||
"fallback_for": "Alternativa para",
|
||||
"fallback_missing": "Faltando alternativa",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está sendo usado na cota \"{quotaName}\"",
|
||||
@@ -1399,9 +1395,6 @@
|
||||
"four_points": "4 pontos",
|
||||
"heading": "Título",
|
||||
"hidden_field_added_successfully": "Campo oculto adicionado com sucesso",
|
||||
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está sendo recordado na pergunta {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Encerramento.",
|
||||
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está sendo recordado no card de Boas-Vindas.",
|
||||
"hide_advanced_settings": "Ocultar configurações avançadas",
|
||||
"hide_back_button": "Ocultar botão 'Voltar'",
|
||||
"hide_back_button_description": "Não exibir o botão de voltar na pesquisa",
|
||||
@@ -1420,7 +1413,6 @@
|
||||
"inner_text": "Texto Interno",
|
||||
"input_border_color": "Cor da borda de entrada",
|
||||
"input_color": "Cor de entrada",
|
||||
"insert_link": "Inserir link",
|
||||
"invalid_targeting": "Segmentação inválida: Por favor, verifique os filtros do seu público",
|
||||
"invalid_video_url_warning": "Por favor, insira uma URL válida do YouTube, Vimeo ou Loom. No momento, não suportamos outros provedores de vídeo.",
|
||||
"invalid_youtube_url": "URL do YouTube inválida",
|
||||
@@ -1438,7 +1430,6 @@
|
||||
"is_set": "Está definido",
|
||||
"is_skipped": "é pulado",
|
||||
"is_submitted": "é submetido",
|
||||
"italic": "Itálico",
|
||||
"jump_to_question": "Pular para a pergunta",
|
||||
"keep_current_order": "Manter pedido atual",
|
||||
"keep_showing_while_conditions_match": "Continue mostrando enquanto as condições corresponderem",
|
||||
@@ -1465,7 +1456,6 @@
|
||||
"no_images_found_for": "Nenhuma imagem encontrada para ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nenhum idioma encontrado. Adicione o primeiro para começar.",
|
||||
"no_option_found": "Nenhuma opção encontrada",
|
||||
"no_recall_items_found": "Nenhum item de recordação encontrado",
|
||||
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
|
||||
"number": "Número",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e excluindo todas as traduções.",
|
||||
@@ -1485,7 +1475,6 @@
|
||||
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
|
||||
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
|
||||
"please_enter_a_file_extension": "Por favor, insira uma extensão de arquivo.",
|
||||
"please_enter_a_valid_url": "Por favor, insira uma URL válida (ex.: https://example.com)",
|
||||
"please_set_a_survey_trigger": "Por favor, configure um gatilho para a pesquisa",
|
||||
"please_specify": "Por favor, especifique",
|
||||
"prevent_double_submission": "Evitar envio duplicado",
|
||||
@@ -1500,8 +1489,6 @@
|
||||
"question_id_updated": "ID da pergunta atualizado",
|
||||
"question_used_in_logic": "Essa pergunta é usada na lógica da pergunta {questionIndex}.",
|
||||
"question_used_in_quota": "Esta questão está sendo usada na cota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Esta pergunta está sendo recordada na pergunta {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Esta pergunta está sendo recordada no card de Encerramento",
|
||||
"quotas": {
|
||||
"add_quota": "Adicionar cota",
|
||||
"change_quota_for_public_survey": "Alterar cota para pesquisa pública?",
|
||||
@@ -1536,8 +1523,6 @@
|
||||
"randomize_all": "Randomizar tudo",
|
||||
"randomize_all_except_last": "Randomizar tudo, exceto o último",
|
||||
"range": "alcance",
|
||||
"recall_data": "Lembrar dados",
|
||||
"recall_information_from": "Recuperar informações de ...",
|
||||
"recontact_options": "Opções de Recontato",
|
||||
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
|
||||
"redirect_to_url": "Redirecionar para URL",
|
||||
@@ -1615,7 +1600,6 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Disparar pesquisa quando uma das ações for executada...",
|
||||
"try_lollipop_or_mountain": "Tenta 'pirulito' ou 'montanha'...",
|
||||
"type_field_id": "Digite o id do campo",
|
||||
"underline": "Sublinhar",
|
||||
"unlock_targeting_description": "Direcione grupos específicos de usuários com base em atributos ou informações do dispositivo",
|
||||
"unlock_targeting_title": "Desbloqueie o direcionamento com um plano superior",
|
||||
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
|
||||
@@ -1632,9 +1616,6 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||
"variable_used_in_recall": "Variável \"{variable}\" está sendo recordada na pergunta {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variável {variable} está sendo recordada no card de Encerramento",
|
||||
"variable_used_in_recall_welcome": "Variável \"{variable}\" está sendo recordada no Card de Boas-Vindas.",
|
||||
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
|
||||
"verify_email_before_submission_description": "Deixe só quem tem um email real responder.",
|
||||
"wait": "Espera",
|
||||
|
||||
@@ -262,9 +262,7 @@
|
||||
"membership_not_found": "Associação não encontrada",
|
||||
"metadata": "Metadados",
|
||||
"minimum": "Mínimo",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
|
||||
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
|
||||
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
|
||||
"move_down": "Mover para baixo",
|
||||
"move_up": "Mover para cima",
|
||||
"multiple_languages": "Várias línguas",
|
||||
@@ -279,7 +277,6 @@
|
||||
"no_result_found": "Nenhum resultado encontrado",
|
||||
"no_results": "Nenhum resultado",
|
||||
"no_surveys_found": "Nenhum inquérito encontrado.",
|
||||
"none_of_the_above": "Nenhuma das opções acima",
|
||||
"not_authenticated": "Não está autenticado para realizar esta ação.",
|
||||
"not_authorized": "Não autorizado",
|
||||
"not_connected": "Não Conectado",
|
||||
@@ -1204,12 +1201,12 @@
|
||||
"add_description": "Adicionar descrição",
|
||||
"add_ending": "Adicionar encerramento",
|
||||
"add_ending_below": "Adicionar encerramento abaixo",
|
||||
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se não houver valor para recordar.",
|
||||
"add_fallback": "Adicionar",
|
||||
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se a pergunta for ignorada:",
|
||||
"add_hidden_field_id": "Adicionar ID do campo oculto",
|
||||
"add_highlight_border": "Adicionar borda de destaque",
|
||||
"add_highlight_border_description": "Adicione uma borda externa ao seu cartão de inquérito.",
|
||||
"add_logic": "Adicionar lógica",
|
||||
"add_none_of_the_above": "Adicionar \"Nenhuma das Opções Acima\"",
|
||||
"add_option": "Adicionar opção",
|
||||
"add_other": "Adicionar \"Outro\"",
|
||||
"add_photo_or_video": "Adicionar foto ou vídeo",
|
||||
@@ -1242,7 +1239,6 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
|
||||
"back_button_label": "Rótulo do botão \"Voltar\"",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"bold": "Negrito",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "Brilho",
|
||||
"button_label": "Rótulo do botão",
|
||||
@@ -1303,8 +1299,8 @@
|
||||
"contains": "Contém",
|
||||
"continue_to_settings": "Continuar para Definições",
|
||||
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de ficheiros podem ser carregados.",
|
||||
"convert_to_multiple_choice": "Converter para Seleção Múltipla",
|
||||
"convert_to_single_choice": "Converter para Seleção Única",
|
||||
"convert_to_multiple_choice": "Converter para Escolha Múltipla",
|
||||
"convert_to_single_choice": "Converter para Escolha Única",
|
||||
"country": "País",
|
||||
"create_group": "Criar grupo",
|
||||
"create_your_own_survey": "Crie o seu próprio inquérito",
|
||||
@@ -1315,6 +1311,7 @@
|
||||
"days_before_showing_this_survey_again": "dias antes de mostrar este inquérito novamente.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a este inquérito.",
|
||||
"delete_choice": "Eliminar escolha",
|
||||
"description": "Descrição",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito",
|
||||
"display_number_of_responses_for_survey": "Mostrar número de respostas do inquérito",
|
||||
@@ -1325,7 +1322,6 @@
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"edit_link": "Editar link",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções {lang}",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
|
||||
@@ -1336,13 +1332,13 @@
|
||||
"ending_card_used_in_logic": "Este cartão final é usado na lógica da pergunta {questionIndex}.",
|
||||
"ending_used_in_quota": "Este final está a ser usado na quota \"{quotaName}\"",
|
||||
"ends_with": "Termina com",
|
||||
"enter_fallback_value": "Inserir valor de substituição",
|
||||
"equals": "Igual",
|
||||
"equals_one_of": "Igual a um de",
|
||||
"error_publishing_survey": "Ocorreu um erro ao publicar o questionário.",
|
||||
"error_saving_changes": "Erro ao guardar alterações",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)",
|
||||
"everyone": "Todos",
|
||||
"fallback_for": "Alternativa para ",
|
||||
"fallback_missing": "Substituição em falta",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Campo oculto \"{fieldId}\" está a ser usado na quota \"{quotaName}\"",
|
||||
@@ -1399,9 +1395,6 @@
|
||||
"four_points": "4 pontos",
|
||||
"heading": "Cabeçalho",
|
||||
"hidden_field_added_successfully": "Campo oculto adicionado com sucesso",
|
||||
"hidden_field_used_in_recall": "Campo oculto \"{hiddenField}\" está a ser recordado na pergunta {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Campo oculto \"{hiddenField}\" está a ser recordado no Cartão de Conclusão",
|
||||
"hidden_field_used_in_recall_welcome": "Campo oculto \"{hiddenField}\" está a ser recordado no cartão de boas-vindas.",
|
||||
"hide_advanced_settings": "Ocultar definições avançadas",
|
||||
"hide_back_button": "Ocultar botão 'Retroceder'",
|
||||
"hide_back_button_description": "Não mostrar o botão de retroceder no inquérito",
|
||||
@@ -1420,7 +1413,6 @@
|
||||
"inner_text": "Texto Interno",
|
||||
"input_border_color": "Cor da borda do campo de entrada",
|
||||
"input_color": "Cor do campo de entrada",
|
||||
"insert_link": "Inserir ligação",
|
||||
"invalid_targeting": "Segmentação inválida: Por favor, verifique os seus filtros de audiência",
|
||||
"invalid_video_url_warning": "Por favor, insira um URL válido do YouTube, Vimeo ou Loom. Atualmente, não suportamos outros fornecedores de hospedagem de vídeo.",
|
||||
"invalid_youtube_url": "URL do YouTube inválido",
|
||||
@@ -1438,7 +1430,6 @@
|
||||
"is_set": "Está definido",
|
||||
"is_skipped": "É ignorado",
|
||||
"is_submitted": "Está submetido",
|
||||
"italic": "Itálico",
|
||||
"jump_to_question": "Saltar para a pergunta",
|
||||
"keep_current_order": "Manter ordem atual",
|
||||
"keep_showing_while_conditions_match": "Continuar a mostrar enquanto as condições corresponderem",
|
||||
@@ -1465,7 +1456,6 @@
|
||||
"no_images_found_for": "Não foram encontradas imagens para ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nenhuma língua encontrada. Adicione a primeira para começar.",
|
||||
"no_option_found": "Nenhuma opção encontrada",
|
||||
"no_recall_items_found": "Nenhum item de recordação encontrado",
|
||||
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
|
||||
"number": "Número",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Depois de definido, o idioma padrão desta pesquisa só pode ser alterado desativando a opção de vários idiomas e eliminando todas as traduções.",
|
||||
@@ -1485,7 +1475,6 @@
|
||||
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
|
||||
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
|
||||
"please_enter_a_file_extension": "Por favor, insira uma extensão de ficheiro.",
|
||||
"please_enter_a_valid_url": "Por favor, insira um URL válido (por exemplo, https://example.com)",
|
||||
"please_set_a_survey_trigger": "Por favor, defina um desencadeador de inquérito",
|
||||
"please_specify": "Por favor, especifique",
|
||||
"prevent_double_submission": "Impedir submissão dupla",
|
||||
@@ -1500,8 +1489,6 @@
|
||||
"question_id_updated": "ID da pergunta atualizado",
|
||||
"question_used_in_logic": "Esta pergunta é usada na lógica da pergunta {questionIndex}.",
|
||||
"question_used_in_quota": "Esta pergunta está a ser usada na quota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Esta pergunta está a ser recordada na pergunta {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Esta pergunta está a ser recordada no Cartão de Conclusão",
|
||||
"quotas": {
|
||||
"add_quota": "Adicionar quota",
|
||||
"change_quota_for_public_survey": "Alterar quota para inquérito público?",
|
||||
@@ -1536,8 +1523,6 @@
|
||||
"randomize_all": "Aleatorizar todos",
|
||||
"randomize_all_except_last": "Aleatorizar todos exceto o último",
|
||||
"range": "Intervalo",
|
||||
"recall_data": "Recuperar dados",
|
||||
"recall_information_from": "Recordar informação de ...",
|
||||
"recontact_options": "Opções de Recontacto",
|
||||
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
|
||||
"redirect_to_url": "Redirecionar para Url",
|
||||
@@ -1615,7 +1600,6 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Desencadear inquérito quando uma das ações for disparada...",
|
||||
"try_lollipop_or_mountain": "Experimente 'lollipop' ou 'mountain'...",
|
||||
"type_field_id": "Escreva o id do campo",
|
||||
"underline": "Sublinhar",
|
||||
"unlock_targeting_description": "Alvo de grupos de utilizadores específicos com base em atributos ou informações do dispositivo",
|
||||
"unlock_targeting_title": "Desbloqueie a segmentação com um plano superior",
|
||||
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
|
||||
@@ -1632,9 +1616,6 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||
"variable_used_in_recall": "Variável \"{variable}\" está a ser recordada na pergunta {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variável {variable} está a ser recordada no Cartão de Conclusão",
|
||||
"variable_used_in_recall_welcome": "Variável \"{variable}\" está a ser recordada no cartão de boas-vindas.",
|
||||
"verify_email_before_submission": "Verificar email antes da submissão",
|
||||
"verify_email_before_submission_description": "Permitir apenas que pessoas com um email real respondam.",
|
||||
"wait": "Aguardar",
|
||||
|
||||
@@ -262,9 +262,7 @@
|
||||
"membership_not_found": "Apartenența nu a fost găsită",
|
||||
"metadata": "Metadate",
|
||||
"minimum": "Minim",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
|
||||
"mobile_overlay_surveys_look_good": "Nu vă faceți griji – chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
|
||||
"mobile_overlay_title": "Ups, ecran mic detectat!",
|
||||
"mobile_overlay_text": "Formbricks nu este disponibil pentru dispozitive cu rezoluții mai mici.",
|
||||
"move_down": "Mută în jos",
|
||||
"move_up": "Mută sus",
|
||||
"multiple_languages": "Mai multe limbi",
|
||||
@@ -279,7 +277,6 @@
|
||||
"no_result_found": "Niciun rezultat găsit",
|
||||
"no_results": "Nicio rezultat",
|
||||
"no_surveys_found": "Nu au fost găsite sondaje.",
|
||||
"none_of_the_above": "Niciuna dintre cele de mai sus",
|
||||
"not_authenticated": "Nu sunteți autentificat pentru a efectua această acțiune.",
|
||||
"not_authorized": "Neautorizat",
|
||||
"not_connected": "Neconectat",
|
||||
@@ -1204,12 +1201,12 @@
|
||||
"add_description": "Adăugați descriere",
|
||||
"add_ending": "Adaugă finalizare",
|
||||
"add_ending_below": "Adaugă finalizare mai jos",
|
||||
"add_fallback_placeholder": "Adaugă un placeholder pentru a afișa dacă nu există valoare de reamintit",
|
||||
"add_fallback": "Adaugă",
|
||||
"add_fallback_placeholder": "Adaugă un substituent pentru a afișa dacă întrebarea este omisă:",
|
||||
"add_hidden_field_id": "Adăugați ID câmp ascuns",
|
||||
"add_highlight_border": "Adaugă bordură evidențiată",
|
||||
"add_highlight_border_description": "Adaugă o margine exterioară cardului tău de sondaj.",
|
||||
"add_logic": "Adaugă logică",
|
||||
"add_none_of_the_above": "Adăugați \"Niciuna dintre cele de mai sus\"",
|
||||
"add_option": "Adăugați opțiune",
|
||||
"add_other": "Adăugați \"Altele\"",
|
||||
"add_photo_or_video": "Adaugă fotografie sau video",
|
||||
@@ -1242,7 +1239,6 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după",
|
||||
"back_button_label": "Etichetă buton \"Înapoi\"",
|
||||
"background_styling": "Stilizare fundal",
|
||||
"bold": "Îngroșat",
|
||||
"brand_color": "Culoarea brandului",
|
||||
"brightness": "Luminozitate",
|
||||
"button_label": "Etichetă buton",
|
||||
@@ -1303,8 +1299,8 @@
|
||||
"contains": "Conține",
|
||||
"continue_to_settings": "Continuă către Setări",
|
||||
"control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.",
|
||||
"convert_to_multiple_choice": "Convertiți la selectare multiplă",
|
||||
"convert_to_single_choice": "Convertiți la selectare unică",
|
||||
"convert_to_multiple_choice": "Convertiți la alegere multiplă",
|
||||
"convert_to_single_choice": "Convertiți la alegere unică",
|
||||
"country": "Țară",
|
||||
"create_group": "Creează grup",
|
||||
"create_your_own_survey": "Creează-ți propriul chestionar",
|
||||
@@ -1315,6 +1311,7 @@
|
||||
"days_before_showing_this_survey_again": "zile înainte de a afișa din nou acest sondaj.",
|
||||
"decide_how_often_people_can_answer_this_survey": "Decide cât de des pot răspunde oamenii la acest sondaj",
|
||||
"delete_choice": "Șterge alegerea",
|
||||
"description": "Descriere",
|
||||
"disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj",
|
||||
"display_number_of_responses_for_survey": "Afișează numărul de răspunsuri pentru sondaj",
|
||||
@@ -1325,7 +1322,6 @@
|
||||
"does_not_include_all_of": "Nu include toate",
|
||||
"does_not_include_one_of": "Nu include una dintre",
|
||||
"does_not_start_with": "Nu începe cu",
|
||||
"edit_link": "Editare legătură",
|
||||
"edit_recall": "Editează Referințele",
|
||||
"edit_translations": "Editează traducerile {lang}",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite participanților să schimbe limba sondajului în orice moment în timpul sondajului.",
|
||||
@@ -1336,13 +1332,13 @@
|
||||
"ending_card_used_in_logic": "Această carte de încheiere este folosită în logica întrebării {questionIndex}.",
|
||||
"ending_used_in_quota": "Finalul acesta este folosit în cota \"{quotaName}\"",
|
||||
"ends_with": "Se termină cu",
|
||||
"enter_fallback_value": "Introduceți valoarea implicită",
|
||||
"equals": "Egal",
|
||||
"equals_one_of": "Egal unu dintre",
|
||||
"error_publishing_survey": "A apărut o eroare în timpul publicării sondajului.",
|
||||
"error_saving_changes": "Eroare la salvarea modificărilor",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Chiar și după ce au furnizat un răspuns (de ex. Cutia de Feedback)",
|
||||
"everyone": "Toată lumea",
|
||||
"fallback_for": "Varianta de rezervă pentru",
|
||||
"fallback_missing": "Rezerva lipsă",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "Câmpul ascuns \"{fieldId}\" este folosit în cota \"{quotaName}\"",
|
||||
@@ -1399,9 +1395,6 @@
|
||||
"four_points": "4 puncte",
|
||||
"heading": "Titlu",
|
||||
"hidden_field_added_successfully": "Câmp ascuns adăugat cu succes",
|
||||
"hidden_field_used_in_recall": "Câmpul ascuns \"{hiddenField}\" este reamintit în întrebarea {questionIndex}.",
|
||||
"hidden_field_used_in_recall_ending_card": "Câmpul ascuns \"{hiddenField}\" este reamintit în Cardul de Încheiere.",
|
||||
"hidden_field_used_in_recall_welcome": "Câmpul ascuns \"{hiddenField}\" este reamintit în cardul de bun venit.",
|
||||
"hide_advanced_settings": "Ascunde setări avansate",
|
||||
"hide_back_button": "Ascunde butonul 'Înapoi'",
|
||||
"hide_back_button_description": "Nu afișa butonul Înapoi în sondaj",
|
||||
@@ -1420,7 +1413,6 @@
|
||||
"inner_text": "Text Interior",
|
||||
"input_border_color": "Culoarea graniței câmpului de introducere",
|
||||
"input_color": "Culoarea câmpului de introducere",
|
||||
"insert_link": "Inserează link",
|
||||
"invalid_targeting": "\"Targetare nevalidă: Vă rugăm să verificați filtrele pentru audiență\"",
|
||||
"invalid_video_url_warning": "Vă rugăm să introduceți un URL valid de YouTube, Vimeo sau Loom. În prezent nu susținem alți furnizori de găzduire video.",
|
||||
"invalid_youtube_url": "URL YouTube invalid",
|
||||
@@ -1438,7 +1430,6 @@
|
||||
"is_set": "Este setat",
|
||||
"is_skipped": "Este sărit",
|
||||
"is_submitted": "Este trimis",
|
||||
"italic": "Cursiv",
|
||||
"jump_to_question": "Sări la întrebare",
|
||||
"keep_current_order": "Păstrați ordinea actuală",
|
||||
"keep_showing_while_conditions_match": "Continuă să afișezi cât timp condițiile se potrivesc",
|
||||
@@ -1465,7 +1456,6 @@
|
||||
"no_images_found_for": "Nicio imagine găsită pentru ''{query}\"",
|
||||
"no_languages_found_add_first_one_to_get_started": "Nu s-au găsit limbi. Adaugă prima pentru a începe.",
|
||||
"no_option_found": "Nicio opțiune găsită",
|
||||
"no_recall_items_found": "Nu s-au găsit elemente de reamintire",
|
||||
"no_variables_yet_add_first_one_below": "Nu există variabile încă. Adăugați prima mai jos.",
|
||||
"number": "Număr",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "Odată setată, limba implicită pentru acest sondaj poate fi schimbată doar dezactivând opțiunea multi-limbă și ștergând toate traducerile.",
|
||||
@@ -1485,7 +1475,6 @@
|
||||
"pin_can_only_contain_numbers": "PIN-ul poate conține doar numere.",
|
||||
"pin_must_be_a_four_digit_number": "PIN-ul trebuie să fie un număr de patru cifre",
|
||||
"please_enter_a_file_extension": "Vă rugăm să introduceți o extensie de fișier.",
|
||||
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid (de exemplu, https://example.com)",
|
||||
"please_set_a_survey_trigger": "Vă rugăm să setați un declanșator sondaj",
|
||||
"please_specify": "Vă rugăm să specificați",
|
||||
"prevent_double_submission": "Prevenire trimitere dublă",
|
||||
@@ -1500,8 +1489,6 @@
|
||||
"question_id_updated": "ID întrebare actualizat",
|
||||
"question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
|
||||
"question_used_in_quota": "Întrebarea aceasta este folosită în cota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Această întrebare este reamintită în întrebarea {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Această întrebare este reamintită în Cardul de Încheiere.",
|
||||
"quotas": {
|
||||
"add_quota": "Adăugați cotă",
|
||||
"change_quota_for_public_survey": "Schimbați cota pentru sondaj public?",
|
||||
@@ -1536,8 +1523,6 @@
|
||||
"randomize_all": "Randomizează tot",
|
||||
"randomize_all_except_last": "Randomizează tot cu excepția ultimului",
|
||||
"range": "Interval",
|
||||
"recall_data": "Reamintiți datele",
|
||||
"recall_information_from": "Reamintiți informațiile din ...",
|
||||
"recontact_options": "Opțiuni de recontactare",
|
||||
"redirect_thank_you_card": "Redirecționează cardul de mulțumire",
|
||||
"redirect_to_url": "Redirecționează către URL",
|
||||
@@ -1615,7 +1600,6 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este realizată...",
|
||||
"try_lollipop_or_mountain": "Încercați „lollipop” sau „mountain”...",
|
||||
"type_field_id": "ID câmp tip",
|
||||
"underline": "Subliniază",
|
||||
"unlock_targeting_description": "Vizează grupuri specifice de utilizatori pe baza atributelor sau a informațiilor despre dispozitiv",
|
||||
"unlock_targeting_title": "Deblocați țintirea cu un plan superior",
|
||||
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
|
||||
@@ -1632,9 +1616,6 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
|
||||
"variable_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.",
|
||||
"variable_used_in_recall": "Variabila \"{variable}\" este reamintită în întrebarea {questionIndex}.",
|
||||
"variable_used_in_recall_ending_card": "Variabila {variable} este reamintită în Cardul de Încheiere.",
|
||||
"variable_used_in_recall_welcome": "Variabila \"{variable}\" este reamintită în cardul de bun venit.",
|
||||
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
|
||||
"verify_email_before_submission_description": "Permite doar persoanelor cu un email real să răspundă.",
|
||||
"wait": "Așteptați",
|
||||
|
||||
@@ -262,9 +262,7 @@
|
||||
"membership_not_found": "未找到会员资格",
|
||||
"metadata": "元数据",
|
||||
"minimum": "最低",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
|
||||
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
|
||||
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
|
||||
"mobile_overlay_text": "Formbricks 不 适用 于 分辨率 较小 的 设备",
|
||||
"move_down": "下移",
|
||||
"move_up": "上移",
|
||||
"multiple_languages": "多种 语言",
|
||||
@@ -279,7 +277,6 @@
|
||||
"no_result_found": "没有 结果",
|
||||
"no_results": "没有 结果",
|
||||
"no_surveys_found": "未找到 调查",
|
||||
"none_of_the_above": "以上 都 不 是",
|
||||
"not_authenticated": "您 未 认证 以 执行 该 操作。",
|
||||
"not_authorized": "未授权",
|
||||
"not_connected": "未连接",
|
||||
@@ -1204,12 +1201,12 @@
|
||||
"add_description": "添加 描述",
|
||||
"add_ending": "添加结尾",
|
||||
"add_ending_below": "在下方 添加 结尾",
|
||||
"add_fallback_placeholder": "添加 占位符 显示 如果 没有 值以 回忆",
|
||||
"add_fallback": "添加",
|
||||
"add_fallback_placeholder": "添加 一个 占位符,以显示该问题是否被跳过:",
|
||||
"add_hidden_field_id": "添加 隐藏 字段 ID",
|
||||
"add_highlight_border": "添加 高亮 边框",
|
||||
"add_highlight_border_description": "在 你的 调查 卡片 添加 外 边框。",
|
||||
"add_logic": "添加逻辑",
|
||||
"add_none_of_the_above": "添加 “以上 都 不 是”",
|
||||
"add_option": "添加 选项",
|
||||
"add_other": "添加 \"其他\"",
|
||||
"add_photo_or_video": "添加 照片 或 视频",
|
||||
@@ -1242,7 +1239,6 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
|
||||
"back_button_label": "\"返回\" 按钮标签",
|
||||
"background_styling": "背景 样式",
|
||||
"bold": "粗体",
|
||||
"brand_color": "品牌 颜色",
|
||||
"brightness": "亮度",
|
||||
"button_label": "按钮标签",
|
||||
@@ -1303,8 +1299,8 @@
|
||||
"contains": "包含",
|
||||
"continue_to_settings": "继续 到 设置",
|
||||
"control_which_file_types_can_be_uploaded": "控制 可以 上传的 文件 类型",
|
||||
"convert_to_multiple_choice": "转换为 多选",
|
||||
"convert_to_single_choice": "转换为 单选",
|
||||
"convert_to_multiple_choice": "转换为多选题",
|
||||
"convert_to_single_choice": "转换为单选题",
|
||||
"country": "国家",
|
||||
"create_group": "创建 群组",
|
||||
"create_your_own_survey": "创建 你 的 调查",
|
||||
@@ -1315,6 +1311,7 @@
|
||||
"days_before_showing_this_survey_again": "显示 此 调查 之前 的 天数。",
|
||||
"decide_how_often_people_can_answer_this_survey": "决定 人 可以 回答 这份 调查 的 频率 。",
|
||||
"delete_choice": "删除 选择",
|
||||
"description": "描述",
|
||||
"disable_the_visibility_of_survey_progress": "禁用问卷 进度 的可见性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "显示 调查 预计 完成 时间",
|
||||
"display_number_of_responses_for_survey": "显示 调查 响应 数量",
|
||||
@@ -1325,7 +1322,6 @@
|
||||
"does_not_include_all_of": "不包括所有 ",
|
||||
"does_not_include_one_of": "不包括一 个",
|
||||
"does_not_start_with": "不 以 开头",
|
||||
"edit_link": "编辑 链接",
|
||||
"edit_recall": "编辑 调用",
|
||||
"edit_translations": "编辑 {lang} 翻译",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "启用 参与者 在 调查 过程中 的 任何 时间 点 切换 调查 语言。",
|
||||
@@ -1336,13 +1332,13 @@
|
||||
"ending_card_used_in_logic": "\"这个 结束卡片 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
|
||||
"ending_used_in_quota": "此 结尾 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"ends_with": "以...结束",
|
||||
"enter_fallback_value": "输入 后备 值",
|
||||
"equals": "等于",
|
||||
"equals_one_of": "等于 其中 一个",
|
||||
"error_publishing_survey": "发布调查时发生了错误",
|
||||
"error_saving_changes": "保存 更改 时 出错",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "即使 他们 提交 了 回复(例如 反馈框)",
|
||||
"everyone": "所有 人",
|
||||
"fallback_for": "后备 用于",
|
||||
"fallback_missing": "备用 缺失",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隐藏 字段 \"{fieldId}\" 正在 被 \"{quotaName}\" 配额 使用",
|
||||
@@ -1399,9 +1395,6 @@
|
||||
"four_points": "4 分",
|
||||
"heading": "标题",
|
||||
"hidden_field_added_successfully": "隐藏字段 添加成功",
|
||||
"hidden_field_used_in_recall": "隐藏 字段 \"{hiddenField}\" 正在召回于问题 {questionIndex}。",
|
||||
"hidden_field_used_in_recall_ending_card": "隐藏 字段 \"{hiddenField}\" 正在召回于结束 卡",
|
||||
"hidden_field_used_in_recall_welcome": "隐藏 字段 \"{hiddenField}\" 正在召回于欢迎 卡 。",
|
||||
"hide_advanced_settings": "隐藏 高级设置",
|
||||
"hide_back_button": "隐藏 \"返回\" 按钮",
|
||||
"hide_back_button_description": "不 显示 调查 中 的 返回 按钮",
|
||||
@@ -1420,7 +1413,6 @@
|
||||
"inner_text": "内文",
|
||||
"input_border_color": "输入 边框 颜色",
|
||||
"input_color": "输入颜色",
|
||||
"insert_link": "插入 链接",
|
||||
"invalid_targeting": "无效的目标: 请检查 您 的受众过滤器",
|
||||
"invalid_video_url_warning": "请输入有效的 YouTube、Vimeo 或 Loom URL 。我们目前不支持其他 视频 托管服务提供商。",
|
||||
"invalid_youtube_url": "无效的 YouTube URL",
|
||||
@@ -1438,7 +1430,6 @@
|
||||
"is_set": "已设置",
|
||||
"is_skipped": "已跳过",
|
||||
"is_submitted": "已提交",
|
||||
"italic": "斜体",
|
||||
"jump_to_question": "跳 转 到 问题",
|
||||
"keep_current_order": "保持 当前 顺序",
|
||||
"keep_showing_while_conditions_match": "条件 符合 时 保持 显示",
|
||||
@@ -1465,7 +1456,6 @@
|
||||
"no_images_found_for": "未找到与 \"{query}\" 相关的图片",
|
||||
"no_languages_found_add_first_one_to_get_started": "没有找到语言。添加第一个以开始。",
|
||||
"no_option_found": "找不到选择",
|
||||
"no_recall_items_found": "未 找到 召回 项目",
|
||||
"no_variables_yet_add_first_one_below": "还没有变量。 在下面添加第一个。",
|
||||
"number": "数字",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "一旦设置,此调查的默认语言只能通过禁用多语言选项并删除所有翻译来更改。",
|
||||
@@ -1485,7 +1475,6 @@
|
||||
"pin_can_only_contain_numbers": "PIN 只能包含数字。",
|
||||
"pin_must_be_a_four_digit_number": "PIN 必须是 四 位数字。",
|
||||
"please_enter_a_file_extension": "请输入 文件 扩展名。",
|
||||
"please_enter_a_valid_url": "请输入有效的 URL(例如, https://example.com )",
|
||||
"please_set_a_survey_trigger": "请 设置 一个 调查 触发",
|
||||
"please_specify": "请 指定",
|
||||
"prevent_double_submission": "防止 重复 提交",
|
||||
@@ -1500,8 +1489,6 @@
|
||||
"question_id_updated": "问题 ID 更新",
|
||||
"question_used_in_logic": "\"这个 问题 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
|
||||
"question_used_in_quota": "此 问题 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"question_used_in_recall": "此问题正在召回于问题 {questionIndex}。",
|
||||
"question_used_in_recall_ending_card": "此 问题 正在召回于结束 卡片。",
|
||||
"quotas": {
|
||||
"add_quota": "添加 配额",
|
||||
"change_quota_for_public_survey": "更改 公共调查 的配额?",
|
||||
@@ -1536,8 +1523,6 @@
|
||||
"randomize_all": "随机排列",
|
||||
"randomize_all_except_last": "随机排列,最后一个除外",
|
||||
"range": "范围",
|
||||
"recall_data": "调用 数据",
|
||||
"recall_information_from": "从 ... 召回信息",
|
||||
"recontact_options": "重新 联系 选项",
|
||||
"redirect_thank_you_card": "重定向感谢卡",
|
||||
"redirect_to_url": "重定向到 URL",
|
||||
@@ -1615,7 +1600,6 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "当 其中 一个 动作 被 触发 时 启动 调查…",
|
||||
"try_lollipop_or_mountain": "尝试 'lollipop' 或 'mountain' ...",
|
||||
"type_field_id": "类型 字段 ID",
|
||||
"underline": "下划线",
|
||||
"unlock_targeting_description": "根据 属性 或 设备信息 定位 特定 用户组",
|
||||
"unlock_targeting_title": "通过 更 高级 划解锁 定位",
|
||||
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
|
||||
@@ -1632,9 +1616,6 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
|
||||
"variable_name_must_start_with_a_letter": "变量名 必须 以字母开头。",
|
||||
"variable_used_in_recall": "变量 \"{variable}\" 正在召回于问题 {questionIndex}。",
|
||||
"variable_used_in_recall_ending_card": "变量 {variable} 正在召回于结束 卡片",
|
||||
"variable_used_in_recall_welcome": "变量 \"{variable}\" 正在召回于欢迎 卡 。",
|
||||
"verify_email_before_submission": "提交 之前 验证电子邮件",
|
||||
"verify_email_before_submission_description": "仅允许 拥有 有效 电子邮件 的 人 回应。",
|
||||
"wait": "等待",
|
||||
|
||||
@@ -262,9 +262,7 @@
|
||||
"membership_not_found": "找不到成員資格",
|
||||
"metadata": "元數據",
|
||||
"minimum": "最小值",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
|
||||
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
|
||||
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
|
||||
"mobile_overlay_text": "Formbricks 不適用於較小解析度的裝置。",
|
||||
"move_down": "下移",
|
||||
"move_up": "上移",
|
||||
"multiple_languages": "多種語言",
|
||||
@@ -279,7 +277,6 @@
|
||||
"no_result_found": "找不到結果",
|
||||
"no_results": "沒有結果",
|
||||
"no_surveys_found": "找不到問卷。",
|
||||
"none_of_the_above": "以上皆非",
|
||||
"not_authenticated": "您未經授權執行此操作。",
|
||||
"not_authorized": "未授權",
|
||||
"not_connected": "未連線",
|
||||
@@ -1204,12 +1201,12 @@
|
||||
"add_description": "新增描述",
|
||||
"add_ending": "新增結尾",
|
||||
"add_ending_below": "在下方新增結尾",
|
||||
"add_fallback_placeholder": "新增 預設 以顯示是否沒 有 值 可 回憶 。",
|
||||
"add_fallback": "新增",
|
||||
"add_fallback_placeholder": "新增用于顯示問題被跳過時的佔位符",
|
||||
"add_hidden_field_id": "新增隱藏欄位 ID",
|
||||
"add_highlight_border": "新增醒目提示邊框",
|
||||
"add_highlight_border_description": "在您的問卷卡片新增外邊框。",
|
||||
"add_logic": "新增邏輯",
|
||||
"add_none_of_the_above": "新增 \"以上皆非\"",
|
||||
"add_option": "新增選項",
|
||||
"add_other": "新增「其他」",
|
||||
"add_photo_or_video": "新增照片或影片",
|
||||
@@ -1242,7 +1239,6 @@
|
||||
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
|
||||
"back_button_label": "「返回」按鈕標籤",
|
||||
"background_styling": "背景樣式設定",
|
||||
"bold": "粗體",
|
||||
"brand_color": "品牌顏色",
|
||||
"brightness": "亮度",
|
||||
"button_label": "按鈕標籤",
|
||||
@@ -1315,6 +1311,7 @@
|
||||
"days_before_showing_this_survey_again": "天後再次顯示此問卷。",
|
||||
"decide_how_often_people_can_answer_this_survey": "決定人們可以回答此問卷的頻率。",
|
||||
"delete_choice": "刪除選項",
|
||||
"description": "描述",
|
||||
"disable_the_visibility_of_survey_progress": "停用問卷進度的可見性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "顯示問卷的估計完成時間",
|
||||
"display_number_of_responses_for_survey": "顯示問卷的回應數",
|
||||
@@ -1325,7 +1322,6 @@
|
||||
"does_not_include_all_of": "不包含全部",
|
||||
"does_not_include_one_of": "不包含其中之一",
|
||||
"does_not_start_with": "不以...開頭",
|
||||
"edit_link": "編輯 連結",
|
||||
"edit_recall": "編輯回憶",
|
||||
"edit_translations": "編輯 '{'language'}' 翻譯",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
|
||||
@@ -1336,13 +1332,13 @@
|
||||
"ending_card_used_in_logic": "此結尾卡片用於問題 '{'questionIndex'}' 的邏輯中。",
|
||||
"ending_used_in_quota": "此 結尾 正被使用於 \"{quotaName}\" 配額中",
|
||||
"ends_with": "結尾為",
|
||||
"enter_fallback_value": "輸入 預設 值",
|
||||
"equals": "等於",
|
||||
"equals_one_of": "等於其中之一",
|
||||
"error_publishing_survey": "發布問卷時發生錯誤。",
|
||||
"error_saving_changes": "儲存變更時發生錯誤",
|
||||
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如,意見反應方塊)",
|
||||
"everyone": "所有人",
|
||||
"fallback_for": "備用 用於 ",
|
||||
"fallback_missing": "遺失的回退",
|
||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "隱藏欄位 \"{fieldId}\" 正被使用於 \"{quotaName}\" 配額中",
|
||||
@@ -1399,9 +1395,6 @@
|
||||
"four_points": "4 分",
|
||||
"heading": "標題",
|
||||
"hidden_field_added_successfully": "隱藏欄位已成功新增",
|
||||
"hidden_field_used_in_recall": "隱藏欄位 \"{hiddenField}\" 於問題 {questionIndex} 中被召回。",
|
||||
"hidden_field_used_in_recall_ending_card": "隱藏欄位 \"{hiddenField}\" 於結束卡中被召回。",
|
||||
"hidden_field_used_in_recall_welcome": "隱藏欄位 \"{hiddenField}\" 於歡迎卡中被召回。",
|
||||
"hide_advanced_settings": "隱藏進階設定",
|
||||
"hide_back_button": "隱藏「Back」按鈕",
|
||||
"hide_back_button_description": "不要在問卷中顯示返回按鈕",
|
||||
@@ -1420,7 +1413,6 @@
|
||||
"inner_text": "內部文字",
|
||||
"input_border_color": "輸入邊框顏色",
|
||||
"input_color": "輸入顏色",
|
||||
"insert_link": "插入 連結",
|
||||
"invalid_targeting": "目標設定無效:請檢查您的受眾篩選器",
|
||||
"invalid_video_url_warning": "請輸入有效的 YouTube、Vimeo 或 Loom 網址。我們目前不支援其他影片託管提供者。",
|
||||
"invalid_youtube_url": "無效的 YouTube 網址",
|
||||
@@ -1438,7 +1430,6 @@
|
||||
"is_set": "已設定",
|
||||
"is_skipped": "已跳過",
|
||||
"is_submitted": "已提交",
|
||||
"italic": "斜體",
|
||||
"jump_to_question": "跳至問題",
|
||||
"keep_current_order": "保留目前順序",
|
||||
"keep_showing_while_conditions_match": "在條件符合時持續顯示",
|
||||
@@ -1465,7 +1456,6 @@
|
||||
"no_images_found_for": "找不到「'{'query'}'」的圖片",
|
||||
"no_languages_found_add_first_one_to_get_started": "找不到語言。新增第一個語言以開始使用。",
|
||||
"no_option_found": "找不到選項",
|
||||
"no_recall_items_found": "找不到 召回 項目",
|
||||
"no_variables_yet_add_first_one_below": "尚無變數。在下方新增第一個變數。",
|
||||
"number": "數字",
|
||||
"once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations": "設定後,此問卷的預設語言只能藉由停用多語言選項並刪除所有翻譯來變更。",
|
||||
@@ -1485,7 +1475,6 @@
|
||||
"pin_can_only_contain_numbers": "PIN 碼只能包含數字。",
|
||||
"pin_must_be_a_four_digit_number": "PIN 碼必須是四位數的數字。",
|
||||
"please_enter_a_file_extension": "請輸入檔案副檔名。",
|
||||
"please_enter_a_valid_url": "請輸入有效的 URL(例如:https://example.com)",
|
||||
"please_set_a_survey_trigger": "請設定問卷觸發器",
|
||||
"please_specify": "請指定",
|
||||
"prevent_double_submission": "防止重複提交",
|
||||
@@ -1500,8 +1489,6 @@
|
||||
"question_id_updated": "問題 ID 已更新",
|
||||
"question_used_in_logic": "此問題用於問題 '{'questionIndex'}' 的邏輯中。",
|
||||
"question_used_in_quota": "此問題 正被使用於 \"{quotaName}\" 配額中",
|
||||
"question_used_in_recall": "此問題於問題 {questionIndex} 中被召回。",
|
||||
"question_used_in_recall_ending_card": "此問題於結尾卡中被召回。",
|
||||
"quotas": {
|
||||
"add_quota": "新增額度",
|
||||
"change_quota_for_public_survey": "更改 公開 問卷 的 額度?",
|
||||
@@ -1536,8 +1523,6 @@
|
||||
"randomize_all": "全部隨機排序",
|
||||
"randomize_all_except_last": "全部隨機排序(最後一項除外)",
|
||||
"range": "範圍",
|
||||
"recall_data": "回憶數據",
|
||||
"recall_information_from": "從 ... 獲取 信息",
|
||||
"recontact_options": "重新聯絡選項",
|
||||
"redirect_thank_you_card": "重新導向感謝卡片",
|
||||
"redirect_to_url": "重新導向至網址",
|
||||
@@ -1615,7 +1600,6 @@
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "當觸發其中一個操作時,觸發問卷...",
|
||||
"try_lollipop_or_mountain": "嘗試「棒棒糖」或「山峰」...",
|
||||
"type_field_id": "輸入欄位 ID",
|
||||
"underline": "下 劃 線",
|
||||
"unlock_targeting_description": "根據屬性或裝置資訊鎖定特定使用者群組",
|
||||
"unlock_targeting_title": "使用更高等級的方案解鎖目標設定",
|
||||
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
|
||||
@@ -1632,9 +1616,6 @@
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
|
||||
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
|
||||
"variable_name_must_start_with_a_letter": "變數名稱必須以字母開頭。",
|
||||
"variable_used_in_recall": "變數 \"{variable}\" 於問題 {questionIndex} 中被召回。",
|
||||
"variable_used_in_recall_ending_card": "變數 {variable} 於 結束 卡 中被召回。",
|
||||
"variable_used_in_recall_welcome": "變數 \"{variable}\" 於 歡迎 Card 中被召回。",
|
||||
"verify_email_before_submission": "提交前驗證電子郵件",
|
||||
"verify_email_before_submission_description": "僅允許擁有真實電子郵件的人員回應。",
|
||||
"wait": "等待",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Languages } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface LanguageDropdownProps {
|
||||
survey: TSurvey;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
|
||||
|
||||
const mockRequest = new Request("http://localhost");
|
||||
@@ -12,15 +12,6 @@ mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn((callback: (scope: any) => void) => {
|
||||
const mockScope = {
|
||||
setTag: vi.fn(),
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
};
|
||||
callback(mockScope);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock SENTRY_DSN constant
|
||||
@@ -241,7 +232,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
expect(errorMock).toHaveBeenCalledWith("API Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
@@ -275,7 +266,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
expect(errorMock).toHaveBeenCalledWith("API Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
@@ -312,7 +303,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
expect(errorMock).toHaveBeenCalledWith("API Error Details");
|
||||
|
||||
// Verify Sentry.captureException was called
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Function is this file can be used in edge runtime functions, like api routes.
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
|
||||
const correlationId = request.headers.get("x-request-id") ?? "";
|
||||
@@ -10,14 +10,14 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
|
||||
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
|
||||
// This is useful for tracking down issues without overloading Sentry with errors
|
||||
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
|
||||
// Use Sentry scope to add correlation ID as a tag for easy filtering
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("correlationId", correlationId);
|
||||
scope.setLevel("error");
|
||||
const err = new Error(`API V2 error, id: ${correlationId}`);
|
||||
|
||||
scope.setExtra("originalError", error);
|
||||
const err = new Error(`API V2 error, id: ${correlationId}`);
|
||||
Sentry.captureException(err);
|
||||
Sentry.captureException(err, {
|
||||
extra: {
|
||||
details: error.details,
|
||||
type: error.type,
|
||||
correlationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,5 +26,5 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
|
||||
correlationId,
|
||||
error,
|
||||
})
|
||||
.error("API V2 Error Details");
|
||||
.error("API Error Details");
|
||||
};
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
// @ts-nocheck // We can remove this when we update the prisma client and the typescript version
|
||||
// if we don't add this we get build errors with prisma due to type-nesting
|
||||
import { ZodCustomIssue, ZodIssue } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { ZodCustomIssue, ZodIssue } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { logApiErrorEdge } from "./utils-edge";
|
||||
|
||||
export const handleApiError = (
|
||||
request: Request,
|
||||
err: ApiErrorResponseV2,
|
||||
auditLog?: TApiAuditLog
|
||||
auditLog?: ApiAuditLog
|
||||
): Response => {
|
||||
logApiError(request, err, auditLog);
|
||||
|
||||
@@ -56,7 +55,7 @@ export const formatZodError = (error: { issues: (ZodIssue | ZodCustomIssue)[] })
|
||||
});
|
||||
};
|
||||
|
||||
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: TApiAuditLog): void => {
|
||||
export const logApiRequest = (request: Request, responseStatus: number, auditLog?: ApiAuditLog): void => {
|
||||
const method = request.method;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
@@ -83,13 +82,13 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
|
||||
logAuditLog(request, auditLog);
|
||||
};
|
||||
|
||||
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: TApiAuditLog): void => {
|
||||
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: ApiAuditLog): void => {
|
||||
logApiErrorEdge(request, error);
|
||||
|
||||
logAuditLog(request, auditLog);
|
||||
};
|
||||
|
||||
const logAuditLog = (request: Request, auditLog?: TApiAuditLog): void => {
|
||||
const logAuditLog = (request: Request, auditLog?: ApiAuditLog): void => {
|
||||
if (AUDIT_LOG_ENABLED && auditLog) {
|
||||
const correlationId = request.headers.get("x-request-id") ?? "";
|
||||
queueAuditEvent({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { Provider } from "next-auth/providers/index";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { createToken } from "@/lib/jwt";
|
||||
// Import mocked rate limiting functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { randomBytes } from "crypto";
|
||||
import { Provider } from "next-auth/providers/index";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authOptions } from "./authOptions";
|
||||
import { mockUser } from "./mock-data";
|
||||
import { hashPassword } from "./utils";
|
||||
@@ -31,7 +31,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SESSION_MAX_AGE: 86400,
|
||||
NEXTAUTH_SECRET: "test-secret",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
|
||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
AUDIT_LOG_GET_USER_IP: false,
|
||||
@@ -261,7 +261,7 @@ describe("authOptions", () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
|
||||
|
||||
const credentials = { token: createToken(mockUser.id) };
|
||||
const credentials = { token: createToken(mockUser.id, mockUser.email) };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Email already verified"
|
||||
@@ -280,7 +280,7 @@ describe("authOptions", () => {
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
|
||||
const result = await tokenProvider.options.authorize(credentials, {});
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
@@ -303,7 +303,7 @@ describe("authOptions", () => {
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
|
||||
@@ -315,7 +315,7 @@ describe("authOptions", () => {
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
@@ -339,7 +339,7 @@ describe("authOptions", () => {
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { TPricingPlan } from "../api/lib/constants";
|
||||
|
||||
interface PricingCardProps {
|
||||
@@ -170,13 +170,14 @@ export const PricingCard = ({
|
||||
|
||||
{plan.id !== projectFeatureKeys.FREE && isCurrentPlan && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onManageSubscription();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center bg-[#635bff]">
|
||||
className="flex justify-center">
|
||||
{t("environments.settings.billing.manage_subscription")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
import { render } from "@react-email/render";
|
||||
import { createTransport } from "nodemailer";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import type { TResponse } from "@formbricks/types/responses";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
|
||||
import {
|
||||
DEBUG,
|
||||
MAIL_FROM,
|
||||
@@ -26,6 +17,15 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
|
||||
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { render } from "@react-email/render";
|
||||
import { createTransport } from "nodemailer";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import type { TResponse } from "@formbricks/types/responses";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
|
||||
import { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
|
||||
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
|
||||
import { VerificationEmail } from "./emails/auth/verification-email";
|
||||
@@ -111,7 +111,7 @@ export const sendVerificationEmail = async ({
|
||||
}): Promise<boolean> => {
|
||||
try {
|
||||
const t = await getTranslate();
|
||||
const token = createToken(id, {
|
||||
const token = createToken(id, email, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
|
||||
@@ -136,7 +136,7 @@ export const sendForgotPasswordEmail = async (user: {
|
||||
locale: TUserLocale;
|
||||
}): Promise<boolean> => {
|
||||
const t = await getTranslate();
|
||||
const token = createToken(user.id, {
|
||||
const token = createToken(user.id, user.email, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import DOMpurify from "isomorphic-dompurify";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
EyeOffIcon,
|
||||
HandshakeIcon,
|
||||
MailIcon,
|
||||
TriangleAlertIcon,
|
||||
UserIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getSurveyFollowUpActionDefaultBody } from "@/modules/survey/editor/lib/utils";
|
||||
@@ -41,25 +60,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import DOMpurify from "isomorphic-dompurify";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
EyeOffIcon,
|
||||
HandshakeIcon,
|
||||
MailIcon,
|
||||
TriangleAlertIcon,
|
||||
UserIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface AddFollowUpModalProps {
|
||||
localSurvey: TSurvey;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRightIcon, EllipsisIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
@@ -15,10 +15,7 @@ const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWi
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-slate-500 hover:text-slate-700",
|
||||
className
|
||||
)}
|
||||
className={cn("flex flex-wrap items-center gap-1.5 break-words text-sm text-slate-500", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -35,7 +32,7 @@ const BreadcrumbItem = React.forwardRef<
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:bg-white hover:outline hover:outline-slate-300",
|
||||
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:outline hover:outline-slate-300",
|
||||
isActive && "bg-slate-100 outline outline-slate-300",
|
||||
isHighlighted && "bg-red-800 text-white outline hover:outline-red-800",
|
||||
className
|
||||
@@ -83,15 +80,14 @@ const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span"
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface BadgeContentProps {
|
||||
id: string | number;
|
||||
@@ -59,8 +59,8 @@ export const BadgeContent: React.FC<BadgeContentProps> = ({
|
||||
};
|
||||
|
||||
const content = (
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/prefer-tag-over-role, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
role={isCopyEnabled ? "button" : undefined}
|
||||
className={getButtonClasses()}
|
||||
onClick={handleCopy}
|
||||
@@ -69,7 +69,7 @@ export const BadgeContent: React.FC<BadgeContentProps> = ({
|
||||
onMouseLeave={isCopyEnabled ? () => setIsHovered(false) : undefined}>
|
||||
<span>{id}</span>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
const getTooltipContent = () => {
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("IdBadge", () => {
|
||||
test("removes interactive elements when copy is disabled", () => {
|
||||
const { container } = render(<IdBadge id="1734" copyDisabled={true} />);
|
||||
|
||||
const badge = container.querySelector("div");
|
||||
const badge = container.querySelector("button");
|
||||
|
||||
// Should not have cursor-pointer class
|
||||
expect(badge).not.toHaveClass("cursor-pointer");
|
||||
|
||||
@@ -1,51 +1,38 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { NoMobileOverlay } from "./index";
|
||||
|
||||
// Mock the tolgee translation
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) =>
|
||||
key === "common.mobile_overlay_text" ? "Please use desktop to access this section" : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("NoMobileOverlay", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders title and paragraphs", () => {
|
||||
test("renders overlay with correct text", () => {
|
||||
render(<NoMobileOverlay />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "common.mobile_overlay_title" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("common.mobile_overlay_app_works_best_on_desktop")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.mobile_overlay_surveys_look_good")).toBeInTheDocument();
|
||||
expect(screen.getByText("Please use desktop to access this section")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("has proper overlay classes (z-index and responsive hide)", () => {
|
||||
test("has proper z-index for overlay", () => {
|
||||
render(<NoMobileOverlay />);
|
||||
|
||||
const overlay = document.querySelector("div.fixed");
|
||||
expect(overlay).toBeInTheDocument();
|
||||
const overlay = screen.getByText("Please use desktop to access this section").closest("div.fixed");
|
||||
expect(overlay).toHaveClass("z-[9999]");
|
||||
});
|
||||
|
||||
test("has responsive layout with sm:hidden class", () => {
|
||||
render(<NoMobileOverlay />);
|
||||
|
||||
const overlay = screen.getByText("Please use desktop to access this section").closest("div.fixed");
|
||||
expect(overlay).toHaveClass("sm:hidden");
|
||||
});
|
||||
|
||||
test("renders learn more link with correct href", () => {
|
||||
render(<NoMobileOverlay />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "common.learn_more" });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "https://formbricks.com/docs/xm-and-surveys/overview");
|
||||
});
|
||||
|
||||
test("stacks icons with maximize centered inside smartphone", () => {
|
||||
const { container } = render(<NoMobileOverlay />);
|
||||
|
||||
const wrapper = container.querySelector("div.relative.h-16.w-16");
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
|
||||
const phoneSvg = wrapper?.querySelector("svg.h-16.w-16");
|
||||
expect(phoneSvg).toBeInTheDocument();
|
||||
|
||||
const expandSvg = wrapper?.querySelector("svg.absolute");
|
||||
expect(expandSvg).toBeInTheDocument();
|
||||
expect(expandSvg).toHaveClass("left-1/2", "top-1/3", "-translate-x-1/2", "-translate-y-1/3");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ExternalLinkIcon, Maximize2Icon, SmartphoneIcon } from "lucide-react";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { SmartphoneIcon, XIcon } from "lucide-react";
|
||||
|
||||
export const NoMobileOverlay = () => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] sm:hidden">
|
||||
<div className="absolute inset-0 bg-slate-50"></div>
|
||||
<div className="relative mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
|
||||
<div className="relative h-16 w-16">
|
||||
<SmartphoneIcon className="text-muted-foreground h-16 w-16" />
|
||||
<Maximize2Icon className="text-muted-foreground absolute left-1/2 top-1/3 h-5 w-5 -translate-x-1/2 -translate-y-1/3" />
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center sm:hidden">
|
||||
<div className="relative h-full w-full bg-slate-50"></div>
|
||||
<div className="bg-slate-850 absolute mx-8 flex flex-col items-center gap-6 rounded-lg px-8 py-10 text-center">
|
||||
<XIcon className="absolute top-14 h-8 w-8 text-slate-500" />
|
||||
<SmartphoneIcon className="h-16 w-16 text-slate-500" />
|
||||
<p className="text-slate-500">{t("common.mobile_overlay_text")}</p>
|
||||
</div>
|
||||
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">
|
||||
{t("common.mobile_overlay_title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
|
||||
{t("common.mobile_overlay_app_works_best_on_desktop")}
|
||||
</p>
|
||||
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
|
||||
{t("common.mobile_overlay_surveys_look_good")}
|
||||
</p>
|
||||
<Button variant="default" asChild className="mt-8">
|
||||
<a href="https://formbricks.com/docs/xm-and-surveys/overview">
|
||||
{t("common.learn_more")}
|
||||
<ExternalLinkIcon />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -424,20 +424,12 @@ EOT
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
fi
|
||||
|
||||
# Step 3: Build service snippets and inject them BEFORE the volumes section (non-destructive: skip if service exists)
|
||||
# Step 3: Build service snippets and inject them BEFORE the volumes section (robust, no sed -i multiline)
|
||||
services_snippet_file="services_snippet.yml"
|
||||
: > "$services_snippet_file"
|
||||
|
||||
insert_traefik="y"
|
||||
if grep -q "^ traefik:" docker-compose.yml; then insert_traefik="n"; fi
|
||||
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
insert_minio="y"; insert_minio_init="y"
|
||||
if grep -q "^ minio:" docker-compose.yml; then insert_minio="n"; fi
|
||||
if grep -q "^ minio-init:" docker-compose.yml; then insert_minio_init="n"; fi
|
||||
|
||||
if [[ $insert_minio == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
cat > "$services_snippet_file" << EOF
|
||||
|
||||
minio:
|
||||
restart: always
|
||||
@@ -466,11 +458,6 @@ EOT
|
||||
- "traefik.http.middlewares.minio-cors.headers.addvaryheader=true"
|
||||
- "traefik.http.middlewares.minio-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.minio-ratelimit.ratelimit.burst=200"
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ $insert_minio_init == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
minio-init:
|
||||
image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868
|
||||
depends_on:
|
||||
@@ -484,11 +471,7 @@ EOF
|
||||
entrypoint: ["/bin/sh", "/tmp/minio-init.sh"]
|
||||
volumes:
|
||||
- ./minio-init.sh:/tmp/minio-init.sh:ro
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ $insert_traefik == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
traefik:
|
||||
image: "traefik:v2.7"
|
||||
restart: always
|
||||
@@ -505,7 +488,6 @@ EOF
|
||||
- ./acme.json:/acme.json
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Downgrade MinIO router to plain HTTP when HTTPS is not configured
|
||||
if [[ $https_setup != "y" ]]; then
|
||||
@@ -515,8 +497,7 @@ EOF
|
||||
sed -i "s|accesscontrolalloworiginlist=https://$domain_name|accesscontrolalloworiginlist=http://$domain_name|" "$services_snippet_file"
|
||||
fi
|
||||
else
|
||||
if [[ $insert_traefik == "y" ]]; then
|
||||
cat > "$services_snippet_file" << EOF
|
||||
cat > "$services_snippet_file" << EOF
|
||||
|
||||
traefik:
|
||||
image: "traefik:v2.7"
|
||||
@@ -533,9 +514,6 @@ EOF
|
||||
- ./acme.json:/acme.json
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
EOF
|
||||
else
|
||||
: > "$services_snippet_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
awk '
|
||||
@@ -551,51 +529,24 @@ EOF
|
||||
|
||||
rm -f "$services_snippet_file"
|
||||
|
||||
# Ensure required volumes exist without removing user-defined volumes
|
||||
if grep -q '^volumes:' docker-compose.yml; then
|
||||
# Ensure postgres
|
||||
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="postgres:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
|
||||
awk '
|
||||
/^volumes:/ { print; invol=1; next }
|
||||
invol && /^[^[:space:]]/ { if(!added){ print " postgres:"; print " driver: local"; added=1 } ; invol=0 }
|
||||
{ print }
|
||||
END { if (invol && !added) { print " postgres:"; print " driver: local" } }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
fi
|
||||
# Ensure redis
|
||||
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="redis:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
|
||||
awk '
|
||||
/^volumes:/ { print; invol=1; next }
|
||||
invol && /^[^[:space:]]/ { if(!added){ print " redis:"; print " driver: local"; added=1 } ; invol=0 }
|
||||
{ print }
|
||||
END { if (invol && !added) { print " redis:"; print " driver: local" } }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
fi
|
||||
# Ensure minio-data if needed
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="minio-data:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
|
||||
awk '
|
||||
/^volumes:/ { print; invol=1; next }
|
||||
invol && /^[^[:space:]]/ { if(!added){ print " minio-data:"; print " driver: local"; added=1 } ; invol=0 }
|
||||
{ print }
|
||||
END { if (invol && !added) { print " minio-data:"; print " driver: local" } }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
fi
|
||||
fi
|
||||
else
|
||||
{
|
||||
echo ""
|
||||
echo "volumes:"
|
||||
echo " postgres:"
|
||||
echo " driver: local"
|
||||
echo " redis:"
|
||||
echo " driver: local"
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
echo " minio-data:"
|
||||
echo " driver: local"
|
||||
fi
|
||||
} >> docker-compose.yml
|
||||
fi
|
||||
# Deterministically rewrite the volumes section to include required volumes
|
||||
awk -v add_minio="$minio_storage" '
|
||||
BEGIN { in_vol=0 }
|
||||
/^volumes:/ {
|
||||
print "volumes:";
|
||||
print " postgres:";
|
||||
print " driver: local";
|
||||
print " uploads:";
|
||||
print " driver: local";
|
||||
if (add_minio == "y") {
|
||||
print " minio-data:";
|
||||
print " driver: local";
|
||||
}
|
||||
in_vol=1; skip=1; next
|
||||
}
|
||||
# Skip original volumes block lines until EOF (we already printed ours)
|
||||
{ if (!skip) print }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Create minio-init script outside heredoc to avoid variable expansion issues
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
@@ -667,6 +618,105 @@ MINIO_SCRIPT_EOF
|
||||
|
||||
docker compose up -d
|
||||
|
||||
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
echo "ℹ️ Waiting for MinIO to be ready..."
|
||||
attempts=0
|
||||
max_attempts=30
|
||||
until docker run --rm --network $(basename "$PWD")_default --entrypoint /bin/sh \
|
||||
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc \
|
||||
"mc alias set minio http://minio:9000 '$minio_root_user' '$minio_root_password' >/dev/null 2>&1 && mc admin info minio >/dev/null 2>&1"; do
|
||||
attempts=$((attempts+1))
|
||||
if [ $attempts -ge $max_attempts ]; then
|
||||
echo "❌ MinIO did not become ready in time. Proceeding, but subsequent steps may fail."
|
||||
break
|
||||
fi
|
||||
echo "...attempt $attempts/$max_attempts"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "ℹ️ Ensuring bucket exists..."
|
||||
docker run --rm --network $(basename "$PWD")_default \
|
||||
-e MINIO_ROOT_USER="$minio_root_user" \
|
||||
-e MINIO_ROOT_PASSWORD="$minio_root_password" \
|
||||
-e MINIO_BUCKET_NAME="$minio_bucket_name" \
|
||||
--entrypoint /bin/sh \
|
||||
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc '
|
||||
mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password" >/dev/null 2>&1;
|
||||
mc mb minio/"$minio_bucket_name" --ignore-existing
|
||||
'
|
||||
|
||||
echo "ℹ️ Ensuring service user and policy exist (idempotent)..."
|
||||
docker run --rm --network $(basename "$PWD")_default \
|
||||
-e MINIO_ROOT_USER="$minio_root_user" \
|
||||
-e MINIO_ROOT_PASSWORD="$minio_root_password" \
|
||||
-e MINIO_SERVICE_USER="$minio_service_user" \
|
||||
-e MINIO_SERVICE_PASSWORD="$minio_service_password" \
|
||||
-e MINIO_BUCKET_NAME="$minio_bucket_name" \
|
||||
--entrypoint /bin/sh \
|
||||
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc '
|
||||
mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password" >/dev/null 2>&1;
|
||||
if ! mc admin policy info minio formbricks-policy >/dev/null 2>&1; then
|
||||
cat > /tmp/formbricks-policy.json << EOF
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{ "Effect": "Allow", "Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], "Resource": ["arn:aws:s3:::$minio_bucket_name/*"] },
|
||||
{ "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::$minio_bucket_name"] }
|
||||
]
|
||||
}
|
||||
EOF
|
||||
mc admin policy create minio formbricks-policy /tmp/formbricks-policy.json >/dev/null 2>&1 || true
|
||||
fi;
|
||||
if ! mc admin user info minio "$minio_service_user" >/dev/null 2>&1; then
|
||||
mc admin user add minio "$minio_service_user" "$minio_service_password" >/dev/null 2>&1 || true
|
||||
fi;
|
||||
mc admin policy attach minio formbricks-policy --user "$minio_service_user" >/dev/null 2>&1 || true
|
||||
'
|
||||
fi
|
||||
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
echo "⏳ Finalizing MinIO setup..."
|
||||
attempts=0; max_attempts=60
|
||||
while cid=$(docker compose ps -q minio-init 2>/dev/null); do
|
||||
status=$(docker inspect -f '{{.State.Status}}' "$cid" 2>/dev/null || echo "")
|
||||
if [ "$status" = "exited" ] || [ -z "$status" ]; then
|
||||
break
|
||||
fi
|
||||
attempts=$((attempts+1))
|
||||
if [ $attempts -ge $max_attempts ]; then
|
||||
echo "⚠️ minio-init still running after wait; proceeding with cleanup anyway."
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "🧹 Cleaning up minio-init service and references..."
|
||||
|
||||
awk '
|
||||
BEGIN{skip=0}
|
||||
/^services:[[:space:]]*$/ { print; next }
|
||||
/^ minio-init:/ { skip=1; next }
|
||||
/^ [A-Za-z0-9_-]+:/ { if (skip) skip=0 }
|
||||
{ if (!skip) print }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Remove list-style "- minio-init" lines under depends_on (if any)
|
||||
sed -E -i '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
|
||||
|
||||
# Remove the minio-init mapping and its condition line
|
||||
sed -i '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
|
||||
|
||||
# Remove any stopped minio-init container and restart without orphans
|
||||
docker compose rm -f -s minio-init >/dev/null 2>&1 || true
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
# Clean up the temporary minio-init script
|
||||
rm -f minio-init.sh
|
||||
|
||||
echo "✅ MinIO one-time init cleaned up."
|
||||
fi
|
||||
|
||||
echo "🔗 To edit more variables and deeper config, go to the formbricks/docker-compose.yml, edit the file, and restart the container!"
|
||||
|
||||
echo "🚨 Make sure you have set up the DNS records as well as inbound rules for the domain name and IP address of this instance."
|
||||
@@ -730,40 +780,6 @@ get_logs() {
|
||||
sudo docker compose logs
|
||||
}
|
||||
|
||||
cleanup_minio_init() {
|
||||
echo "🧹 Cleaning up MinIO init service and references..."
|
||||
cd formbricks
|
||||
|
||||
# Remove minio-init service block from docker-compose.yml
|
||||
awk '
|
||||
BEGIN{skip=0}
|
||||
/^services:[[:space:]]*$/ { print; next }
|
||||
/^ minio-init:/ { skip=1; next }
|
||||
/^ [A-Za-z0-9_-]+:/ { if (skip) skip=0 }
|
||||
{ if (!skip) print }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Remove list-style "- minio-init" lines under depends_on (if any)
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -E -i '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
|
||||
else
|
||||
sed -E -i '' '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
|
||||
fi
|
||||
|
||||
# Remove the minio-init mapping and its condition line (mapping style depends_on)
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
|
||||
else
|
||||
sed -i '' '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
|
||||
fi
|
||||
|
||||
# Remove any stopped minio-init container and restart without orphans
|
||||
docker compose rm -f -s minio-init >/dev/null 2>&1 || true
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
echo "✅ MinIO init cleanup complete."
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
install)
|
||||
install_formbricks
|
||||
@@ -780,9 +796,6 @@ restart)
|
||||
logs)
|
||||
get_logs
|
||||
;;
|
||||
cleanup-minio-init)
|
||||
cleanup_minio_init
|
||||
;;
|
||||
uninstall)
|
||||
uninstall_formbricks
|
||||
;;
|
||||
|
||||
@@ -6,37 +6,23 @@ icon: "key"
|
||||
|
||||
To unlock Formbricks Enterprise Edition features, you need to activate your Enterprise License Key. Follow these steps to activate your license:
|
||||
|
||||
<Steps>
|
||||
<Step title="Set the License Key">
|
||||
Add your Enterprise License Key as an environment variable in your deployment:
|
||||
## 1. Set the License Key
|
||||
|
||||
```bash
|
||||
ENTERPRISE_LICENSE_KEY=
|
||||
```
|
||||
Add your Enterprise License Key as an environment variable in your deployment:
|
||||
|
||||
- Add your Enterprise License Key after `ENTERPRISE_LICENSE_KEY=` with the key you received from Formbricks.
|
||||
- How you set environment variables depends on your deployment (Docker, Kubernetes, .env file, etc.).
|
||||
</Step>
|
||||
```bash
|
||||
ENTERPRISE_LICENSE_KEY=
|
||||
```
|
||||
|
||||
<Step title="Restart Your Instance">
|
||||
After setting the environment variable, **restart your Formbricks instance** to apply the changes.
|
||||
</Step>
|
||||
- Add your Enterprise License Key after `ENTERPRISE_LICENSE_KEY=` with the key you received from Formbricks.
|
||||
- How you set environment variables depends on your deployment (Docker, Kubernetes, .env file, etc.).
|
||||
|
||||
<Step title="Verify License Activation">
|
||||
To verify if your license is active, visit `Organization Settings` -> `Enterprise License` to check the confirmation screen.
|
||||
</Step>
|
||||
## 2. Restart Your Instance
|
||||
|
||||
<Step title="Ensure License Server is Reachable">
|
||||
Your Formbricks instance performs a daily license check to verify your Enterprise License. If you're deploying Formbricks behind a firewall or have network restrictions, you need to whitelist the following:
|
||||
After setting the environment variable, **restart your Formbricks instance** to apply the changes.
|
||||
|
||||
- **Domain**: `ee.formbricks.com`
|
||||
- **URL**: `https://ee.formbricks.com/api/licenses/check`
|
||||
- **Protocol**: HTTPS (Port 443)
|
||||
- **Method**: POST
|
||||
- **Frequency**: Every 24 hours
|
||||
## 3. Verify License Activation
|
||||
To verify if your license is active, visit `Organization Settings` -> `Enterprise License` to check the confirmation screen.
|
||||
|
||||
The license check includes a 3-day grace period. If the check fails temporarily, your instance will continue using cached license information for up to 3 days.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
If you have questions or need assistance with network configuration, please reach out to hola@formbricks.com.
|
||||
### Troubleshooting
|
||||
Your server needs to be able to reach the Formbricks License Server. In case you're deploying Formbricks behind a firewall, please reach out to hola@formbricks.com for more info.
|
||||
@@ -4,10 +4,10 @@ description: "License for Formbricks"
|
||||
icon: "file-certificate"
|
||||
---
|
||||
|
||||
The Formbricks core source code is licensed under AGPLv3 and available on GitHub. Additionally, we offer features for bigger organisations & enterprises under a separate, paid Enterprise License. This assures the long-term sustainability of the open source project. All free features are listed [below](#what-features-are-free%3F).
|
||||
The Formbricks core source code is licensed under AGPLv3 and available on GitHub. Additionally, we offer features for bigger organisations & enterprises under a separate, paid Enterprise License. This assures the long-term sustainability of the open source project. All free features are listed [below](#what-features-are-free).
|
||||
|
||||
<Note>
|
||||
Want to get your hands on the Enterprise Edition? [Request a free Enterprise Edition
|
||||
Want to get your hands on the Enterprise Edition? [Request a free 60-day Enterprise Edition
|
||||
Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of
|
||||
Concept.
|
||||
</Note>
|
||||
@@ -18,17 +18,21 @@ Additional to the AGPLv3 licensed Formbricks core, the Formbricks repository con
|
||||
|
||||
## When do I need an Enterprise License?
|
||||
|
||||
| | Community Edition | Enterprise Edition |
|
||||
| | Community Edition | Enterprise License |
|
||||
| ------------------------------------------------------------- | ----------------- | ------------------ |
|
||||
| Self-host for commercial purposes | ✅ | ✅ |
|
||||
| Fork codebase, make changes, release under AGPLv3 | ✅ | ✅ |
|
||||
| Self-host for commercial purposes | ✅ | No license needed |
|
||||
| Fork codebase, make changes, release under AGPLv3 | ✅ | No license needed |
|
||||
| Fork codebase, make changes, **keep private** | ❌ | ✅ |
|
||||
| Unlimited responses | ✅ | Pay per response |
|
||||
| Unlimited surveys | ✅ | ✅ |
|
||||
| Unlimited users | ✅ | ✅ |
|
||||
| Unlimited responses | ✅ | No license needed |
|
||||
| Unlimited surveys | ✅ | No license needed |
|
||||
| Unlimited users | ✅ | No license needed |
|
||||
| Projects | 3 | Unlimited |
|
||||
| Use all [free features](#what-features-are-free%3F) | ✅ | ✅ |
|
||||
| Use [paid features](#what-features-are-free%3F) | ❌ | Pay per feature |
|
||||
| Use any of the other [free features](#what-features-are-free) | ✅ | No license needed |
|
||||
| Remove branding | ❌ | ✅ |
|
||||
| SSO | ❌ | ✅ |
|
||||
| Contacts & Targeting | ❌ | ✅ |
|
||||
| Teams & access roles | ❌ | ✅ |
|
||||
| Use any of the [paid features](#what-features-are-free) | ❌ | ✅ |
|
||||
|
||||
## Open Core Licensing
|
||||
|
||||
@@ -41,14 +45,14 @@ The Formbricks core application is licensed under the [AGPLv3 Open Source Licens
|
||||
Additional to the AGPL licensed Formbricks core, this repository contains code licensed under an Enterprise license. The [code](https://github.com/formbricks/formbricks/tree/main/apps/web/modules/ee) and [license](https://github.com/formbricks/formbricks/blob/main/apps/web/modules/ee/LICENSE) for the enterprise functionality can be found in the `/apps/web/modules/ee` folder of this repository. This additional functionality is not part of the AGPLv3 licensed Formbricks core and is designed to meet the needs of larger teams and enterprises. This advanced functionality is already included in the Docker images, but you need an [Enterprise License Key](https://formbricks.com/enterprise-license?source=docs) to unlock it.
|
||||
|
||||
<Note>
|
||||
Want to get your hands on the Enterprise Edition? [Request a free Enterprise Edition
|
||||
Want to get your hands on the Enterprise Edition? [Request a free 60-day Enterprise Edition
|
||||
Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of
|
||||
Concept.
|
||||
</Note>
|
||||
|
||||
## White-Labeling Formbricks and Other Licensing Needs
|
||||
|
||||
We offer Formbricks white-labeled in some cases. [Please send us an email with a project description and we'll get back to you.](mailto:hola@formbricks.com).
|
||||
We currently do not offer Formbricks white-labeled. Any other needs? [Send us an email](mailto:hola@formbricks.com).
|
||||
|
||||
## Why charge for Enterprise Features?
|
||||
|
||||
@@ -59,8 +63,6 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| Feature | Community Edition | Enterprise Edition |
|
||||
| ---------------------------------------------- | ----------------- | ------------------ |
|
||||
| Unlimited surveys | ✅ | ✅ |
|
||||
| Full API Access | ✅ | ✅ |
|
||||
| All SDKs | ✅ | ✅ |
|
||||
| Website & App surveys | ✅ | ✅ |
|
||||
| Link surveys | ✅ | ✅ |
|
||||
| Email embedded surveys | ✅ | ✅ |
|
||||
@@ -75,18 +77,18 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| Hidden fields | ✅ | ✅ |
|
||||
| Single-use links | ✅ | ✅ |
|
||||
| Pin-protected surveys | ✅ | ✅ |
|
||||
| Full API Access | ✅ | ✅ |
|
||||
| All SDKs | ✅ | ✅ |
|
||||
| Webhooks | ✅ | ✅ |
|
||||
| Email follow-ups | ✅ | ✅ |
|
||||
| Multi-language UI | ✅ | ✅ |
|
||||
| All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ |
|
||||
| Domain Split Configuration | ✅ | ✅ |
|
||||
| Cluster Hosting via Formbricks Helm Chart | ✅ | ✅ |
|
||||
| Hide "Powered by Formbricks" | ❌ | ✅ |
|
||||
| Whitelabel email follow-ups | ❌ | ✅ |
|
||||
| Teams & access roles | ❌ | ✅ |
|
||||
| Contact management & segments | ❌ | ✅ |
|
||||
| Multi-language surveys | ❌ | ✅ |
|
||||
| Quota Management | ❌ | ✅ |
|
||||
| Audit Logs | ❌ | ✅ |
|
||||
| OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
|
||||
| SAML SSO | ❌ | ✅ |
|
||||
@@ -96,4 +98,4 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| White-glove onboarding | ❌ | ✅ |
|
||||
| Support SLAs | ❌ | ✅ |
|
||||
|
||||
Questions? [Send us an email](mailto:johannes@formbricks.com) or [book a call with us.](https://cal.com/johannes/license)
|
||||
**Any more questions?** [Send us an email](mailto:johannes@formbricks.com) or [book a call with us.](https://cal.com/johannes/license)
|
||||
|
||||
@@ -9,7 +9,8 @@ icon: "arrow-right"
|
||||
<Warning>
|
||||
**Important: Migration Required**
|
||||
|
||||
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
|
||||
Formbricks 4 introduces additional requirements for self-hosting setups and makes a dedicated Redis cache as well as S3-compatible file storage mandatory.
|
||||
|
||||
</Warning>
|
||||
|
||||
Formbricks 4.0 is a **major milestone** that sets up the technical foundation for future iterations and feature improvements. This release focuses on modernizing core infrastructure components to improve reliability, scalability, and enable advanced features going forward.
|
||||
@@ -17,9 +18,11 @@ Formbricks 4.0 is a **major milestone** that sets up the technical foundation fo
|
||||
### What's New in Formbricks 4.0
|
||||
|
||||
**🚀 New Enterprise Features:**
|
||||
|
||||
- **Quotas Management**: Advanced quota controls for enterprise users
|
||||
|
||||
**🏗️ Technical Foundation Improvements:**
|
||||
|
||||
- **Enhanced File Storage**: Improved file handling with better performance and reliability
|
||||
- **Improved Caching**: New caching functionality improving speed, extensibility and reliability
|
||||
- **Database Optimization**: Removal of unused database tables and fields for better performance
|
||||
@@ -39,7 +42,8 @@ These services are already included in the updated one-click setup for self-host
|
||||
We know this represents more moving parts in your infrastructure and might even introduce more complexity in hosting Formbricks, and we don't take this decision lightly. As Formbricks grows into a comprehensive Survey and Experience Management platform, we've reached a point where the simple, single-service approach was holding back our ability to deliver the reliable, feature-rich product our users demand and deserve.
|
||||
|
||||
By moving to dedicated, professional-grade services for these critical functions, we're building the foundation needed to deliver:
|
||||
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
|
||||
|
||||
- **Enterprise-grade reliability** with proper redundancy and backup capabilities
|
||||
- **Advanced features** that require sophisticated caching and file processing
|
||||
- **Better performance** through optimized, dedicated services
|
||||
- **Future scalability** to support larger deployments and more complex use cases without the need to maintain two different approaches
|
||||
@@ -52,7 +56,7 @@ Additional migration steps are needed if you are using a self-hosted Formbricks
|
||||
|
||||
### One-Click Setup
|
||||
|
||||
For users using our official one-click setup, we provide an automated migration using a migration script:
|
||||
For users using our official one-click setup, we provide an automated migration using a migration script:
|
||||
|
||||
```bash
|
||||
# Download the latest script
|
||||
@@ -71,7 +75,6 @@ This script guides you through the steps for the infrastructure migration and do
|
||||
- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it
|
||||
- Pulls the latest Formbricks image and updates your instance
|
||||
|
||||
|
||||
### Manual Setup
|
||||
|
||||
If you use a different setup to host your Formbricks instance, you need to make sure to make the necessary adjustments to run Formbricks 4.0.
|
||||
@@ -112,8 +115,8 @@ docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbr
|
||||
```
|
||||
|
||||
<Info>
|
||||
If you run into "**No such container**", use `docker ps` to find your container name,
|
||||
e.g. `formbricks_postgres_1`.
|
||||
If you run into "**No such container**", use `docker ps` to find your container name, e.g.
|
||||
`formbricks_postgres_1`.
|
||||
</Info>
|
||||
|
||||
**2. Upgrade to Formbricks 4.0**
|
||||
|
||||
@@ -31,6 +31,7 @@ Use cloud storage services for production deployments:
|
||||
|
||||
- **AWS S3** (Amazon Web Services)
|
||||
- **DigitalOcean Spaces**
|
||||
- **Backblaze B2**
|
||||
- **Wasabi**
|
||||
- **StorJ**
|
||||
- Any S3-compatible storage service
|
||||
@@ -120,13 +121,6 @@ S3_ENDPOINT_URL=https://your-endpoint.com
|
||||
S3_FORCE_PATH_STYLE=1
|
||||
```
|
||||
|
||||
<Note>
|
||||
<strong>AWS S3 vs. third‑party S3:</strong> When using AWS S3 directly, leave `S3_ENDPOINT_URL` unset and
|
||||
set `S3_FORCE_PATH_STYLE=0` (or omit). For most third‑party S3‑compatible providers (e.g., MinIO,
|
||||
DigitalOcean Spaces, Wasabi, Storj), you typically must set `S3_ENDPOINT_URL` to the provider's endpoint and
|
||||
set `S3_FORCE_PATH_STYLE=1`.
|
||||
</Note>
|
||||
|
||||
## Provider-Specific Examples
|
||||
|
||||
### AWS S3
|
||||
@@ -162,13 +156,16 @@ S3_ENDPOINT_URL=https://files.yourdomain.com
|
||||
S3_FORCE_PATH_STYLE=1
|
||||
```
|
||||
|
||||
### Compatibility requirement: S3 POST Object support
|
||||
### Backblaze B2
|
||||
|
||||
Formbricks uses the S3 [POST Object](https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPOST.html)
|
||||
operation (presigned POST) for uploads. Your object storage provider must support this operation. Providers
|
||||
that do not implement POST Object are not compatible with Formbricks uploads. For example, Backblaze B2's
|
||||
S3‑compatible API currently does not support POST Object and therefore will not work with Formbricks file
|
||||
uploads.
|
||||
```bash
|
||||
S3_ACCESS_KEY=your_b2_key_id
|
||||
S3_SECRET_KEY=your_b2_application_key
|
||||
S3_REGION=us-west-000
|
||||
S3_BUCKET_NAME=my-formbricks-bucket
|
||||
S3_ENDPOINT_URL=https://s3.us-west-000.backblazeb2.com
|
||||
S3_FORCE_PATH_STYLE=1
|
||||
```
|
||||
|
||||
## Bundled MinIO Setup
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ Running Formbricks as a cluster of multiple instances offers several key advanta
|
||||
|
||||
To run Formbricks in a cluster setup, you'll need:
|
||||
|
||||
- Enterprise Edition license key
|
||||
|
||||
- Shared PostgreSQL database
|
||||
|
||||
- Shared Redis cache for session management and caching
|
||||
|
||||
@@ -312,18 +312,6 @@ To restart Formbricks, simply run the following command:
|
||||
|
||||
The script will automatically restart all the Formbricks related containers and brings the entire stack up with the previous configuration.
|
||||
|
||||
## Cleanup MinIO init (optional)
|
||||
|
||||
During the one-click setup, a temporary `minio-init` service configures MinIO (bucket, policy, service user). It is idempotent and safe to leave in place; it will do nothing on subsequent starts once configuration exists.
|
||||
|
||||
If you prefer to remove the `minio-init` service and its references after a successful setup, run:
|
||||
|
||||
```
|
||||
./formbricks.sh cleanup-minio-init
|
||||
```
|
||||
|
||||
This only removes the init job and its Compose references; it does not delete any data or affect your MinIO configuration.
|
||||
|
||||
## Uninstall
|
||||
|
||||
To uninstall Formbricks, simply run the following command, but keep in mind that this will delete all your data!
|
||||
|
||||
@@ -8,6 +8,10 @@ icon: "chart-pie"
|
||||
|
||||
Quota Management allows you to set limits on the number of responses collected for specific segments or criteria in your survey. This feature helps ensure you collect a balanced and representative dataset while preventing oversaturation of certain response types.
|
||||
|
||||
<Note type="warning">
|
||||
Quota Management is currently in beta and only available to select customers.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Quota Management is part of the [Enterprise Edition](/self-hosting/advanced/license).
|
||||
</Note>
|
||||
|
||||
@@ -82,8 +82,8 @@ describe("client.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client when access key is missing (IAM role authentication)", async () => {
|
||||
// Mock constants with missing access key - should work with IAM roles
|
||||
test("should return error when access key is missing", async () => {
|
||||
// Mock constants with missing access key
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: undefined,
|
||||
@@ -93,20 +93,14 @@ describe("client.ts", () => {
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
region: mockConstants.S3_REGION,
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("s3_credentials_error");
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client when secret key is missing (IAM role authentication)", async () => {
|
||||
// Mock constants with missing secret key - should work with IAM roles
|
||||
test("should return error when secret key is missing", async () => {
|
||||
// Mock constants with missing secret key
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_SECRET_KEY: undefined,
|
||||
@@ -116,20 +110,14 @@ describe("client.ts", () => {
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
region: mockConstants.S3_REGION,
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("s3_credentials_error");
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client when both credentials are missing (IAM role authentication)", async () => {
|
||||
// Mock constants with no credentials - should work with IAM roles
|
||||
test("should return error when both credentials are missing", async () => {
|
||||
// Mock constants with no credentials
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: undefined,
|
||||
@@ -140,20 +128,14 @@ describe("client.ts", () => {
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
region: mockConstants.S3_REGION,
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("s3_credentials_error");
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client when credentials are empty strings (IAM role authentication)", async () => {
|
||||
// Mock constants with empty string credentials - should work with IAM roles
|
||||
test("should return error when credentials are empty strings", async () => {
|
||||
// Mock constants with empty string credentials
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: "",
|
||||
@@ -164,20 +146,14 @@ describe("client.ts", () => {
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
region: mockConstants.S3_REGION,
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("s3_credentials_error");
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client when mixed empty and undefined credentials (IAM role authentication)", async () => {
|
||||
// Mock constants with mixed empty and undefined - should work with IAM roles
|
||||
test("should return error when mixed empty and undefined credentials", async () => {
|
||||
// Mock constants with mixed empty and undefined
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: "",
|
||||
@@ -188,81 +164,6 @@ describe("client.ts", () => {
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
region: mockConstants.S3_REGION,
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client when region is missing (uses AWS SDK defaults)", async () => {
|
||||
// Mock constants with missing region - should still work
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_REGION: undefined,
|
||||
}));
|
||||
|
||||
const { createS3ClientFromEnv } = await import("./client");
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
credentials: {
|
||||
accessKeyId: mockConstants.S3_ACCESS_KEY,
|
||||
secretAccessKey: mockConstants.S3_SECRET_KEY,
|
||||
},
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client with only bucket name (minimal config for IAM roles)", async () => {
|
||||
// Mock constants with only bucket name - minimal required config
|
||||
vi.doMock("./constants", () => ({
|
||||
S3_ACCESS_KEY: undefined,
|
||||
S3_SECRET_KEY: undefined,
|
||||
S3_REGION: undefined,
|
||||
S3_BUCKET_NAME: "test-bucket",
|
||||
S3_ENDPOINT_URL: undefined,
|
||||
S3_FORCE_PATH_STYLE: false,
|
||||
}));
|
||||
|
||||
const { createS3ClientFromEnv } = await import("./client");
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
endpoint: undefined,
|
||||
forcePathStyle: false,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when bucket name is missing", async () => {
|
||||
// Mock constants with missing bucket name
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_BUCKET_NAME: undefined,
|
||||
}));
|
||||
|
||||
const { createS3ClientFromEnv } = await import("./client");
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("s3_credentials_error");
|
||||
@@ -353,10 +254,11 @@ describe("client.ts", () => {
|
||||
});
|
||||
|
||||
test("should return undefined when creating from env fails and no client provided", async () => {
|
||||
// Mock constants with missing bucket name (the only required field)
|
||||
// Mock constants with missing credentials
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_BUCKET_NAME: undefined,
|
||||
S3_ACCESS_KEY: undefined,
|
||||
S3_SECRET_KEY: undefined,
|
||||
}));
|
||||
|
||||
const { createS3Client } = await import("./client");
|
||||
@@ -388,7 +290,8 @@ describe("client.ts", () => {
|
||||
test("returns undefined when env is invalid and does not construct client", async () => {
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_BUCKET_NAME: undefined,
|
||||
S3_ACCESS_KEY: undefined,
|
||||
S3_SECRET_KEY: undefined,
|
||||
}));
|
||||
|
||||
const { getCachedS3Client } = await import("./client");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { S3Client, type S3ClientConfig } from "@aws-sdk/client-s3";
|
||||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { type Result, type StorageError, StorageErrorCode, err, ok } from "../types/error";
|
||||
import {
|
||||
@@ -19,35 +19,19 @@ let cachedS3Client: S3Client | undefined;
|
||||
*/
|
||||
export const createS3ClientFromEnv = (): Result<S3Client, StorageError> => {
|
||||
try {
|
||||
// Only S3_BUCKET_NAME is required - S3_REGION is optional and will default to AWS SDK defaults
|
||||
if (!S3_BUCKET_NAME) {
|
||||
logger.error("S3 Client: S3_BUCKET_NAME is required");
|
||||
if (!S3_ACCESS_KEY || !S3_SECRET_KEY || !S3_BUCKET_NAME || !S3_REGION) {
|
||||
logger.error("S3 Client: S3 credentials are not set");
|
||||
return err({
|
||||
code: StorageErrorCode.S3CredentialsError,
|
||||
});
|
||||
}
|
||||
|
||||
// Build S3 client configuration
|
||||
const s3Config: S3ClientConfig = {
|
||||
const s3ClientInstance = new S3Client({
|
||||
credentials: { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY },
|
||||
region: S3_REGION,
|
||||
endpoint: S3_ENDPOINT_URL,
|
||||
forcePathStyle: S3_FORCE_PATH_STYLE,
|
||||
};
|
||||
|
||||
// Only set region if it's provided, otherwise let AWS SDK use its defaults
|
||||
if (S3_REGION) {
|
||||
s3Config.region = S3_REGION;
|
||||
}
|
||||
|
||||
// Only add credentials if both access key and secret key are provided
|
||||
// This allows the AWS SDK to use IAM roles, instance profiles, or other credential providers
|
||||
if (S3_ACCESS_KEY && S3_SECRET_KEY) {
|
||||
s3Config.credentials = {
|
||||
accessKeyId: S3_ACCESS_KEY,
|
||||
secretAccessKey: S3_SECRET_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
const s3ClientInstance = new S3Client(s3Config);
|
||||
});
|
||||
|
||||
return ok(s3ClientInstance);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
## Overview
|
||||
|
||||
The `@formbricks/surveys` package provides a complete survey rendering system built with Preact/React. It features automated translation management through Lingo.dev.
|
||||
|
||||
## Features
|
||||
|
||||
- **Survey Components**: Complete set of survey question types and UI components
|
||||
- **Internationalization**: Built with i18next and react-i18next
|
||||
- **Type Safety**: Full TypeScript support
|
||||
- **Testing**: Comprehensive test coverage with Vitest
|
||||
- **Lightweight**: Built with Preact for optimal bundle size
|
||||
- **Multi-language Support**: Supports 10+ languages with automated translation generation
|
||||
|
||||
## Architecture
|
||||
|
||||
### File Structure
|
||||
|
||||
```text
|
||||
packages/surveys/
|
||||
├── locales/ # Translation files
|
||||
│ ├── en.json # Source translations (English)
|
||||
│ ├── de.json # Generated translations (German)
|
||||
│ ├── fr.json # Generated translations (French)
|
||||
│ └── ... # Other target languages
|
||||
├── i18n.json # lingo.dev configuration
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── buttons/ # Survey navigation buttons
|
||||
│ │ ├── general/ # Core survey components
|
||||
│ │ ├── i18n/
|
||||
│ │ │ └── provider.tsx # i18n provider component
|
||||
│ │ ├── icons/ # Icon components
|
||||
│ │ ├── questions/ # Question type components
|
||||
│ │ └── wrappers/ # Layout wrappers
|
||||
│ ├── lib/
|
||||
│ │ ├── i18n.config.ts # i18next configuration
|
||||
│ │ ├── i18n-utils.ts # Utility functions
|
||||
│ │ └── ... # Other utilities
|
||||
│ ├── styles/ # CSS styles
|
||||
│ └── types/ # TypeScript types
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Setting Up Automated Translations
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Lingo.dev](https://Lingo.dev) API key
|
||||
- Access to the Formbricks team on Lingo.dev
|
||||
|
||||
### Step-by-Step Setup
|
||||
|
||||
1. **Join the Formbricks Team**
|
||||
|
||||
- Join the Formbricks team on Lingo.dev
|
||||
|
||||
2. **Get Your API Key**
|
||||
|
||||
- In the sidebar, go to **Projects** and open the default project
|
||||
- Navigate to the **Settings** tab
|
||||
- Copy the API key
|
||||
|
||||
3. **Configure Environment Variables**
|
||||
|
||||
In the surveys package directory, create a `.env` file:
|
||||
|
||||
```bash
|
||||
# packages/surveys/.env
|
||||
LINGODOTDEV_API_KEY=<YOUR_API_KEY>
|
||||
```
|
||||
|
||||
4. **Generate Translations**
|
||||
|
||||
Run the translation generation script:
|
||||
|
||||
```bash
|
||||
# From the root of the repo or from within the surveys package
|
||||
pnpm run i18n:generate
|
||||
```
|
||||
|
||||
This will execute the auto-translate script and update translation files if needed.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Adding New Translation Keys
|
||||
|
||||
1. **Update Source File**: Add new keys to `packages/surveys/locales/en.json`
|
||||
2. **Generate Translations**: Run `pnpm run i18n:generate`
|
||||
3. **Update Components**: Use the new translation keys in your components with `useTranslation` hook
|
||||
4. **Test**: Verify translations work across all supported languages
|
||||
|
||||
### Updating Existing Translations
|
||||
|
||||
1. **Update Target File**: Update the translation keys in the target language file (`packages/surveys/locales/<target-language>.json`)
|
||||
2. **Test**: Verify translations work across all supported languages
|
||||
3. You don't need to run the `i18n:generate` command as it is only required when the source language is updated.
|
||||
|
||||
### Adding New Languages
|
||||
|
||||
#### 1. Update lingo.dev Configuration
|
||||
|
||||
Edit `packages/surveys/i18n.json` to include new target languages:
|
||||
|
||||
```json
|
||||
{
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["de", "it", ...otherLanguages, "new-lang"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Update i18n Configuration
|
||||
|
||||
Modify `packages/surveys/src/lib/i18n.config.ts`:
|
||||
|
||||
```tsx
|
||||
// Add new import
|
||||
import newLangTranslations from "../../locales/new-lang.json";
|
||||
|
||||
i18n
|
||||
.use(ICU)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
supportedLngs: ["en", "de", ...otherLanguages, "new-lang"],
|
||||
resources: {
|
||||
// ... existing resources
|
||||
"new-lang": { translation: newLangTranslations },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. Generate Translation Files
|
||||
|
||||
Run the translation generation command:
|
||||
|
||||
```bash
|
||||
pnpm run i18n:generate
|
||||
```
|
||||
|
||||
This will create new translation files in the `locales/` directory for each target language.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm dev` - Start development build
|
||||
- `pnpm build` - Build for production
|
||||
- `pnpm test` - Run tests
|
||||
- `pnpm test:coverage` - Run tests with coverage
|
||||
- `pnpm i18n:generate` - Generate translations using Lingo.dev
|
||||
- `pnpm lint` - Lint and fix code
|
||||
@@ -54,9 +54,7 @@ checksums:
|
||||
errors/invalid_device_error/message: 8813dcd0e3e41934af18d7a15f8c83f4
|
||||
errors/invalid_device_error/title: 20d261b478aaba161b0853a588926e23
|
||||
errors/please_book_an_appointment: 9e8acea3721f660b6a988f79c4105ab8
|
||||
errors/please_enter_a_valid_email_address: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||
errors/please_enter_a_valid_phone_number: 1530eb9ab7d6d190bddb37667c711631
|
||||
errors/please_enter_a_valid_url: e3bcfb605be4ee32aa19d9ac32bb11a4
|
||||
errors/please_fill_out_this_field: 88d4fd502ae8d423277aef723afcd1a7
|
||||
errors/please_rank_all_items_before_submitting: 24fb14a2550bd7ec3e253dda0997cea8
|
||||
errors/please_select_a_date: 1abdc8ffb887dbbdcc0d05486cd84de7
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "هذا الجهاز لا يدعم حماية البريد المزعج."
|
||||
},
|
||||
"please_book_an_appointment": "يرجى حجز موعد",
|
||||
"please_enter_a_valid_email_address": "الرجاء إدخال عنوان بريد إلكتروني صالح",
|
||||
"please_enter_a_valid_phone_number": "يرجى إدخال رقم هاتف صحيح",
|
||||
"please_enter_a_valid_url": "الرجاء إدخال عنوان URL صالح",
|
||||
"please_fill_out_this_field": "يرجى ملء هذا الحقل",
|
||||
"please_rank_all_items_before_submitting": "يرجى ترتيب جميع العناصر قبل الإرسال",
|
||||
"please_select_a_date": "يرجى اختيار تاريخ",
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "Dieses Gerät unterstützt keinen Spam-Schutz."
|
||||
},
|
||||
"please_book_an_appointment": "Bitte vereinbaren Sie einen Termin",
|
||||
"please_enter_a_valid_email_address": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
|
||||
"please_enter_a_valid_phone_number": "Bitte geben Sie eine gültige Telefonnummer ein",
|
||||
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein",
|
||||
"please_fill_out_this_field": "Bitte füllen Sie dieses Feld aus",
|
||||
"please_rank_all_items_before_submitting": "Bitte ordnen Sie alle Elemente vor dem Absenden",
|
||||
"please_select_a_date": "Bitte wählen Sie ein Datum aus",
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "This device doesn’t support spam protection."
|
||||
},
|
||||
"please_book_an_appointment": "Please book an appointment",
|
||||
"please_enter_a_valid_email_address": "Please enter a valid email address",
|
||||
"please_enter_a_valid_phone_number": "Please enter a valid phone number",
|
||||
"please_enter_a_valid_url": "Please enter a valid URL",
|
||||
"please_fill_out_this_field": "Please fill out this field",
|
||||
"please_rank_all_items_before_submitting": "Please rank all items before submitting",
|
||||
"please_select_a_date": "Please select a date",
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "Este dispositivo no es compatible con la protección contra spam."
|
||||
},
|
||||
"please_book_an_appointment": "Por favor, reserve una cita",
|
||||
"please_enter_a_valid_email_address": "Por favor, introduce una dirección de correo electrónico válida",
|
||||
"please_enter_a_valid_phone_number": "Por favor, introduzca un número de teléfono válido",
|
||||
"please_enter_a_valid_url": "Por favor, introduce una URL válida",
|
||||
"please_fill_out_this_field": "Por favor, complete este campo",
|
||||
"please_rank_all_items_before_submitting": "Por favor, clasifique todos los elementos antes de enviar",
|
||||
"please_select_a_date": "Por favor, seleccione una fecha",
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "Cet appareil ne prend pas en charge la protection contre le spam."
|
||||
},
|
||||
"please_book_an_appointment": "Veuillez prendre rendez-vous",
|
||||
"please_enter_a_valid_email_address": "Veuillez saisir une adresse e-mail valide",
|
||||
"please_enter_a_valid_phone_number": "Veuillez saisir un numéro de téléphone valide",
|
||||
"please_enter_a_valid_url": "Veuillez saisir une URL valide",
|
||||
"please_fill_out_this_field": "Veuillez remplir ce champ",
|
||||
"please_rank_all_items_before_submitting": "Veuillez classer tous les éléments avant de soumettre",
|
||||
"please_select_a_date": "Veuillez sélectionner une date",
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "यह डिवाइस स्पैम सुरक्षा का समर्थन नहीं करता है।"
|
||||
},
|
||||
"please_book_an_appointment": "कृपया एक अपॉइंटमेंट बुक करें",
|
||||
"please_enter_a_valid_email_address": "कृपया एक वैध ईमेल पता दर्ज करें",
|
||||
"please_enter_a_valid_phone_number": "कृपया एक वैध फोन नंबर दर्ज करें",
|
||||
"please_enter_a_valid_url": "कृपया एक वैध URL दर्ज करें",
|
||||
"please_fill_out_this_field": "कृपया इस फील्ड को भरें",
|
||||
"please_rank_all_items_before_submitting": "जमा करने से पहले कृपया सभी आइटम्स को रैंक करें",
|
||||
"please_select_a_date": "कृपया एक तारीख चुनें",
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "Questo dispositivo non supporta la protezione anti-spam."
|
||||
},
|
||||
"please_book_an_appointment": "Prenota un appuntamento",
|
||||
"please_enter_a_valid_email_address": "Inserisci un indirizzo email valido",
|
||||
"please_enter_a_valid_phone_number": "Inserisci un numero di telefono valido",
|
||||
"please_enter_a_valid_url": "Inserisci un URL valido",
|
||||
"please_fill_out_this_field": "Compila questo campo",
|
||||
"please_rank_all_items_before_submitting": "Classifica tutti gli elementi prima di inviare",
|
||||
"please_select_a_date": "Seleziona una data",
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "このデバイスはスパム保護に対応していません。"
|
||||
},
|
||||
"please_book_an_appointment": "予約をお取りください",
|
||||
"please_enter_a_valid_email_address": "有効なメールアドレスを入力してください",
|
||||
"please_enter_a_valid_phone_number": "有効な電話番号を入力してください",
|
||||
"please_enter_a_valid_url": "有効なURLを入力してください",
|
||||
"please_fill_out_this_field": "このフィールドに入力してください",
|
||||
"please_rank_all_items_before_submitting": "送信する前にすべての項目をランク付けしてください",
|
||||
"please_select_a_date": "日付を選択してください",
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "Este dispositivo não suporta proteção contra spam."
|
||||
},
|
||||
"please_book_an_appointment": "Por favor, marque uma consulta",
|
||||
"please_enter_a_valid_email_address": "Por favor, insira um endereço de email válido",
|
||||
"please_enter_a_valid_phone_number": "Por favor, insira um número de telefone válido",
|
||||
"please_enter_a_valid_url": "Por favor, insira uma URL válida",
|
||||
"please_fill_out_this_field": "Por favor, preencha este campo",
|
||||
"please_rank_all_items_before_submitting": "Por favor, classifique todos os itens antes de enviar",
|
||||
"please_select_a_date": "Por favor, selecione uma data",
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "Acest dispozitiv nu acceptă protecția împotriva spamului."
|
||||
},
|
||||
"please_book_an_appointment": "Vă rugăm să faceți o programare",
|
||||
"please_enter_a_valid_email_address": "Vă rugăm să introduceți o adresă de email validă",
|
||||
"please_enter_a_valid_phone_number": "Vă rugăm să introduceți un număr de telefon valid",
|
||||
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid",
|
||||
"please_fill_out_this_field": "Vă rugăm să completați acest câmp",
|
||||
"please_rank_all_items_before_submitting": "Vă rugăm să clasificați toate elementele înainte de a trimite",
|
||||
"please_select_a_date": "Vă rugăm să selectați o dată",
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "Это устройство не поддерживает защиту от спама."
|
||||
},
|
||||
"please_book_an_appointment": "Пожалуйста, запишитесь на приём",
|
||||
"please_enter_a_valid_email_address": "Пожалуйста, введите действительный адрес электронной почты",
|
||||
"please_enter_a_valid_phone_number": "Пожалуйста, введите действительный номер телефона",
|
||||
"please_enter_a_valid_url": "Пожалуйста, введите действительный URL-адрес",
|
||||
"please_fill_out_this_field": "Пожалуйста, заполните это поле",
|
||||
"please_rank_all_items_before_submitting": "Пожалуйста, оцените все элементы перед отправкой",
|
||||
"please_select_a_date": "Пожалуйста, выберите дату",
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "Ushbu qurilma spam himoyasini qo'llab-quvvatlamaydi."
|
||||
},
|
||||
"please_book_an_appointment": "Iltimos, uchrashuvni bron qiling",
|
||||
"please_enter_a_valid_email_address": "Iltimos, toʻgʻri elektron pochta manzilini kiriting",
|
||||
"please_enter_a_valid_phone_number": "Iltimos, to'g'ri telefon raqamini kiriting",
|
||||
"please_enter_a_valid_url": "Iltimos, toʻgʻri URL manzilini kiriting",
|
||||
"please_fill_out_this_field": "Iltimos, ushbu maydonni to'ldiring",
|
||||
"please_rank_all_items_before_submitting": "Iltimos, yuborishdan oldin barcha elementlarni baholang",
|
||||
"please_select_a_date": "Iltimos, sanani tanlang",
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
"title": "此设备不支持垃圾邮件保护。"
|
||||
},
|
||||
"please_book_an_appointment": "请预约",
|
||||
"please_enter_a_valid_email_address": "请输入有效的电子邮件地址",
|
||||
"please_enter_a_valid_phone_number": "请输入有效的电话号码",
|
||||
"please_enter_a_valid_url": "请输入有效的URL",
|
||||
"please_fill_out_this_field": "请填写此字段",
|
||||
"please_rank_all_items_before_submitting": "请在提交之前对所有项目进行排名",
|
||||
"please_select_a_date": "请选择一个日期",
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { type RefObject } from "preact";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ZEmail, ZUrl } from "@formbricks/types/common";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { BackButton } from "@/components/buttons/back-button";
|
||||
import { SubmitButton } from "@/components/buttons/submit-button";
|
||||
import { Headline } from "@/components/general/headline";
|
||||
@@ -12,6 +6,11 @@ import { Subheader } from "@/components/general/subheader";
|
||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { type RefObject } from "preact";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface OpenTextQuestionProps {
|
||||
question: TSurveyOpenTextQuestion;
|
||||
@@ -53,6 +52,7 @@ export function OpenTextQuestion({
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,38 +72,13 @@ export function OpenTextQuestion({
|
||||
const input = inputRef.current;
|
||||
input?.setCustomValidity("");
|
||||
|
||||
// Check required field
|
||||
if (question.required && (!value || value.trim() === "")) {
|
||||
input?.setCustomValidity(t("errors.please_fill_out_this_field"));
|
||||
input?.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate input format if value is provided
|
||||
if (value && value.trim() !== "") {
|
||||
if (question.inputType === "email") {
|
||||
if (!ZEmail.safeParse(value).success) {
|
||||
input?.setCustomValidity(t("errors.please_enter_a_valid_email_address"));
|
||||
input?.reportValidity();
|
||||
return;
|
||||
}
|
||||
} else if (question.inputType === "url") {
|
||||
if (!ZUrl.safeParse(value).success) {
|
||||
input?.setCustomValidity(t("errors.please_enter_a_valid_url"));
|
||||
input?.reportValidity();
|
||||
return;
|
||||
}
|
||||
} else if (question.inputType === "phone") {
|
||||
const phoneRegex = /^[+]?[\d\s\-()]{7,}$/;
|
||||
if (!phoneRegex.test(value)) {
|
||||
input?.setCustomValidity(t("errors.please_enter_a_valid_phone_number"));
|
||||
input?.reportValidity();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All validation passed
|
||||
// at this point, validity is clean
|
||||
const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtc);
|
||||
onSubmit({ [question.id]: value }, updatedTtc);
|
||||
@@ -139,21 +114,12 @@ export function OpenTextQuestion({
|
||||
value={value ? value : ""}
|
||||
type={question.inputType}
|
||||
onInput={(e) => {
|
||||
const input = e.currentTarget;
|
||||
handleInputChange(input.value);
|
||||
// Clear any previous validation errors while typing
|
||||
input.setCustomValidity("");
|
||||
handleInputChange(e.currentTarget.value);
|
||||
}}
|
||||
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
|
||||
pattern=".*"
|
||||
pattern={question.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"}
|
||||
title={
|
||||
question.inputType === "phone"
|
||||
? t("errors.please_enter_a_valid_phone_number")
|
||||
: question.inputType === "email"
|
||||
? t("errors.please_enter_a_valid_email_address")
|
||||
: question.inputType === "url"
|
||||
? t("errors.please_enter_a_valid_url")
|
||||
: undefined
|
||||
question.inputType === "phone" ? t("errors.please_enter_a_valid_phone_number") : undefined
|
||||
}
|
||||
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
|
||||
maxLength={
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
import { render } from "@testing-library/preact";
|
||||
import { useRef } from "preact/hooks";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { useClickOutside } from "./use-click-outside-hook";
|
||||
|
||||
describe("useClickOutside", () => {
|
||||
let container: HTMLDivElement;
|
||||
let outsideElement: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create container for testing
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Create an outside element
|
||||
outsideElement = document.createElement("div");
|
||||
outsideElement.id = "outside";
|
||||
document.body.appendChild(outsideElement);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up
|
||||
document.body.removeChild(container);
|
||||
document.body.removeChild(outsideElement);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should call handler when clicking outside the ref element", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const TestComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, handler);
|
||||
return (
|
||||
<div ref={ref} id="inside">
|
||||
Inside
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />, { container });
|
||||
|
||||
// Simulate mousedown and click outside
|
||||
const mousedownEvent = new MouseEvent("mousedown", { bubbles: true });
|
||||
Object.defineProperty(mousedownEvent, "target", { value: outsideElement, configurable: true });
|
||||
document.dispatchEvent(mousedownEvent);
|
||||
|
||||
const clickEvent = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(clickEvent, "target", { value: outsideElement, configurable: true });
|
||||
document.dispatchEvent(clickEvent);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should not call handler when clicking inside the ref element", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const TestComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, handler);
|
||||
return (
|
||||
<div ref={ref} id="inside">
|
||||
Inside
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { container: componentContainer } = render(<TestComponent />, { container });
|
||||
const insideElement = componentContainer.querySelector("#inside")!;
|
||||
|
||||
// Simulate mousedown and click inside
|
||||
const mousedownEvent = new MouseEvent("mousedown", { bubbles: true });
|
||||
Object.defineProperty(mousedownEvent, "target", { value: insideElement, configurable: true });
|
||||
document.dispatchEvent(mousedownEvent);
|
||||
|
||||
const clickEvent = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(clickEvent, "target", { value: insideElement, configurable: true });
|
||||
document.dispatchEvent(clickEvent);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not call handler when mousedown started inside (even if click is outside)", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const TestComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, handler);
|
||||
return (
|
||||
<div ref={ref} id="inside">
|
||||
Inside
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { container: componentContainer } = render(<TestComponent />, { container });
|
||||
const insideElement = componentContainer.querySelector("#inside")!;
|
||||
|
||||
// Simulate mousedown inside
|
||||
const mousedownEvent = new MouseEvent("mousedown", { bubbles: true });
|
||||
Object.defineProperty(mousedownEvent, "target", { value: insideElement, configurable: true });
|
||||
document.dispatchEvent(mousedownEvent);
|
||||
|
||||
// But click outside
|
||||
const clickEvent = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(clickEvent, "target", { value: outsideElement, configurable: true });
|
||||
document.dispatchEvent(clickEvent);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle touch events (touchstart and click)", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const TestComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, handler);
|
||||
return (
|
||||
<div ref={ref} id="inside">
|
||||
Inside
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />, { container });
|
||||
|
||||
// Simulate touchstart and click outside
|
||||
const touchstartEvent = new TouchEvent("touchstart", { bubbles: true });
|
||||
Object.defineProperty(touchstartEvent, "target", { value: outsideElement, configurable: true });
|
||||
document.dispatchEvent(touchstartEvent);
|
||||
|
||||
const clickEvent = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(clickEvent, "target", { value: outsideElement, configurable: true });
|
||||
document.dispatchEvent(clickEvent);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should not call handler when touchstart started inside", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const TestComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, handler);
|
||||
return (
|
||||
<div ref={ref} id="inside">
|
||||
Inside
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { container: componentContainer } = render(<TestComponent />, { container });
|
||||
const insideElement = componentContainer.querySelector("#inside")!;
|
||||
|
||||
// Simulate touchstart inside
|
||||
const touchstartEvent = new TouchEvent("touchstart", { bubbles: true });
|
||||
Object.defineProperty(touchstartEvent, "target", { value: insideElement, configurable: true });
|
||||
document.dispatchEvent(touchstartEvent);
|
||||
|
||||
// Click outside
|
||||
const clickEvent = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(clickEvent, "target", { value: outsideElement, configurable: true });
|
||||
document.dispatchEvent(clickEvent);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle clicks on descendant elements", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const TestComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, handler);
|
||||
return (
|
||||
<div ref={ref} id="parent">
|
||||
<div id="child">Child</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { container: componentContainer } = render(<TestComponent />, { container });
|
||||
const childElement = componentContainer.querySelector("#child")!;
|
||||
|
||||
// Simulate mousedown and click on child element
|
||||
const mousedownEvent = new MouseEvent("mousedown", { bubbles: true });
|
||||
Object.defineProperty(mousedownEvent, "target", { value: childElement, configurable: true });
|
||||
document.dispatchEvent(mousedownEvent);
|
||||
|
||||
const clickEvent = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(clickEvent, "target", { value: childElement, configurable: true });
|
||||
document.dispatchEvent(clickEvent);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should clean up event listeners on unmount", () => {
|
||||
const handler = vi.fn();
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const TestComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, handler);
|
||||
return (
|
||||
<div ref={ref} id="inside">
|
||||
Inside
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { unmount } = render(<TestComponent />, { container });
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith("mousedown", expect.any(Function));
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith("touchstart", expect.any(Function));
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith("click", expect.any(Function));
|
||||
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should not call handler when ref.current is null during click", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const TestComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, handler);
|
||||
return <div id="inside">Inside (no ref)</div>;
|
||||
};
|
||||
|
||||
render(<TestComponent />, { container });
|
||||
|
||||
// Simulate mousedown and click outside
|
||||
const mousedownEvent = new MouseEvent("mousedown", { bubbles: true });
|
||||
Object.defineProperty(mousedownEvent, "target", { value: outsideElement, configurable: true });
|
||||
document.dispatchEvent(mousedownEvent);
|
||||
|
||||
const clickEvent = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(clickEvent, "target", { value: outsideElement, configurable: true });
|
||||
document.dispatchEvent(clickEvent);
|
||||
|
||||
// Handler should not be called because ref.current is null
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle case when event.target is not a Node", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const TestComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, handler);
|
||||
return (
|
||||
<div ref={ref} id="inside">
|
||||
Inside
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render(<TestComponent />, { container });
|
||||
|
||||
// Simulate mousedown with valid target
|
||||
const mousedownEvent = new MouseEvent("mousedown", { bubbles: true });
|
||||
Object.defineProperty(mousedownEvent, "target", { value: outsideElement, configurable: true });
|
||||
document.dispatchEvent(mousedownEvent);
|
||||
|
||||
// Simulate click with null target (edge case)
|
||||
const clickEvent = new MouseEvent("click", { bubbles: true });
|
||||
Object.defineProperty(clickEvent, "target", { value: null, configurable: true });
|
||||
document.dispatchEvent(clickEvent);
|
||||
|
||||
// Handler should be called because ref.current is valid DOM element
|
||||
// and event.target (null) is not contained in it
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MutableRef, useEffect } from "preact/hooks";
|
||||
import { isDOMElement } from "@/lib/utils";
|
||||
|
||||
// Improved version of https://usehooks.com/useOnClickOutside/
|
||||
export const useClickOutside = (
|
||||
@@ -14,14 +13,14 @@ export const useClickOutside = (
|
||||
// Do nothing if `mousedown` or `touchstart` started inside ref element
|
||||
if (startedInside || !startedWhenMounted) return;
|
||||
// Do nothing if clicking ref's element or descendent elements
|
||||
if (!isDOMElement(ref.current) || ref.current.contains(event.target as Node)) return;
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) return;
|
||||
|
||||
handler(event);
|
||||
};
|
||||
|
||||
const validateEventStart = (event: MouseEvent | TouchEvent) => {
|
||||
startedWhenMounted = isDOMElement(ref.current);
|
||||
startedInside = isDOMElement(ref.current) && ref.current.contains(event.target as Node);
|
||||
startedWhenMounted = ref.current !== null;
|
||||
startedInside = ref.current !== null && ref.current.contains(event.target as Node);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", validateEventStart);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
|
||||
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
|
||||
import { type ApiErrorResponse } from "@formbricks/types/errors";
|
||||
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
type TSurveyQuestion,
|
||||
type TSurveyQuestionChoice,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
|
||||
|
||||
export const cn = (...classes: string[]) => {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
@@ -197,8 +197,3 @@ export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCo
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Helper function to check if a value is a DOM element with contains method
|
||||
export const isDOMElement = (element: unknown): element is HTMLElement => {
|
||||
return element instanceof HTMLElement;
|
||||
};
|
||||
|
||||
@@ -4,8 +4,6 @@ export const ZBoolean = z.boolean();
|
||||
|
||||
export const ZString = z.string();
|
||||
|
||||
export const ZUrl = z.string().url();
|
||||
|
||||
export const ZNumber = z.number();
|
||||
|
||||
export const ZOptionalNumber = z.number().optional();
|
||||
@@ -180,5 +178,3 @@ export const safeUrlRefinement = (url: string, ctx: z.RefinementCtx): void => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const ZEmail = z.string().email();
|
||||
|
||||
Reference in New Issue
Block a user