resolve conflict

This commit is contained in:
Balázs Úr
2026-03-18 11:51:28 +01:00
50 changed files with 1251 additions and 95 deletions

View File

@@ -0,0 +1,146 @@
"use client";
import type { TFunction } from "i18next";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { Badge } from "@/modules/ui/components/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
type TFeatureDefinition = {
key: TPublicLicenseFeatureKey;
labelKey: string;
docsUrl: string;
};
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
return [
{
key: "contacts",
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
},
{
key: "projects",
labelKey: t("environments.settings.enterprise.license_feature_projects"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
},
{
key: "whitelabel",
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
},
{
key: "removeBranding",
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
},
{
key: "twoFactorAuth",
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
},
{
key: "sso",
labelKey: t("environments.settings.enterprise.license_feature_sso"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
},
{
key: "saml",
labelKey: t("environments.settings.enterprise.license_feature_saml"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
},
{
key: "spamProtection",
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
},
{
key: "auditLogs",
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
},
{
key: "accessControl",
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
},
{
key: "quotas",
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
},
];
};
interface EnterpriseLicenseFeaturesTableProps {
features: TEnterpriseLicenseFeatures;
}
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
const { t } = useTranslation();
return (
<SettingsCard
title={t("environments.settings.enterprise.license_features_table_title")}
description={t("environments.settings.enterprise.license_features_table_description")}
noPadding>
<Table>
<TableHeader>
<TableRow className="hover:bg-white">
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
<TableHead>{t("common.documentation")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getFeatureDefinitions(t).map((feature) => {
const value = features[feature.key];
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
let displayValue: number | string = "—";
if (typeof value === "number") {
displayValue = value;
} else if (value === null) {
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
}
return (
<TableRow key={feature.key} className="hover:bg-white">
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
<TableCell>
<Badge
type={isEnabled ? "success" : "gray"}
size="normal"
text={
isEnabled
? t("environments.settings.enterprise.license_features_table_enabled")
: t("environments.settings.enterprise.license_features_table_disabled")
}
/>
</TableCell>
<TableCell className="text-slate-600">{displayValue}</TableCell>
<TableCell>
<Link
href={feature.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
{t("common.read_docs")}
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</SettingsCard>
);
};

View File

@@ -15,6 +15,7 @@ import { SettingsCard } from "../../../components/SettingsCard";
interface EnterpriseLicenseStatusProps {
status: TLicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date;
environmentId: string;
}
@@ -44,6 +45,7 @@ const getBadgeConfig = (
export const EnterpriseLicenseStatus = ({
status,
lastChecked,
gracePeriodEnd,
environmentId,
}: EnterpriseLicenseStatusProps) => {
@@ -92,7 +94,19 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<div className="flex flex-wrap items-center gap-3">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")}{" "}
{new Date(lastChecked).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</span>
</div>
</div>
<Button
type="button"

View File

@@ -10,6 +10,7 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
@@ -93,15 +94,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
/>
</PageHeader>
{hasLicense ? (
<EnterpriseLicenseStatus
status={licenseState.status}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
<>
<EnterpriseLicenseStatus
status={licenseState.status}
lastChecked={licenseState.lastChecked}
gracePeriodEnd={
licenseState.status === "unreachable"
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
: undefined
}
environmentId={params.environmentId}
/>
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</>
) : (
<div>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">

View File

@@ -64,15 +64,17 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
organizationId,
access: [
{
type: "organization",
@@ -81,12 +83,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;

View File

@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { organizationId, project } = useEnvironment();
const { project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,7 +128,6 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
organizationId: organizationId,
projectId: project.id,
});
if (result?.data) {

View File

@@ -1014,6 +1014,25 @@ checksums:
environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12
environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44
environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a
environments/settings/enterprise/license_feature_access_control: bdc5ce7e88ad724d4abd3e8a07a9de5d
environments/settings/enterprise/license_feature_audit_logs: e93f59c176cfc8460d2bd56551ed78b8
environments/settings/enterprise/license_feature_contacts: fd76522bc82324ac914e124cdf9935b0
environments/settings/enterprise/license_feature_projects: 8ba082a84aa35cf851af1cf874b853e2
environments/settings/enterprise/license_feature_quotas: e6afead11b5b8ae627885ce2b84a548f
environments/settings/enterprise/license_feature_remove_branding: a5c71d43cd3ed25e6e48bca64e8ffc9f
environments/settings/enterprise/license_feature_saml: 86b76024524fc585b2c3950126ef6f62
environments/settings/enterprise/license_feature_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
environments/settings/enterprise/license_feature_sso: 8c029b7dd2cb3aa1393d2814aba6cd7b
environments/settings/enterprise/license_feature_two_factor_auth: bc68ddd9c3c82225ef641f097e0940db
environments/settings/enterprise/license_feature_whitelabel: 81e9ec1d4230419f4230e6f5a318497c
environments/settings/enterprise/license_features_table_access: 550606d4a12bdf108c1b12b925ca1b3a
environments/settings/enterprise/license_features_table_description: d6260830d0703f5a2c9ed59c9da462e3
environments/settings/enterprise/license_features_table_disabled: 0889a3dfd914a7ef638611796b17bf72
environments/settings/enterprise/license_features_table_enabled: 20236664b7e62df0e767921b4450205f
environments/settings/enterprise/license_features_table_feature: 58f5f3f37862b6312a2f20ec1a1fd0e8
environments/settings/enterprise/license_features_table_title: 82d1d7b30d876cf4312f78140a90e394
environments/settings/enterprise/license_features_table_unlimited: e1a92523172cd1bdde5550689840e42d
environments/settings/enterprise/license_features_table_value: 34b0eaa85808b15cbc4be94c64d0146b
environments/settings/enterprise/license_instance_mismatch_description: 00f47e33ff54fca52ce9b125cd77fda5
environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526
environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8
@@ -1629,6 +1648,7 @@ checksums:
environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69
environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0
environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486
environments/surveys/edit/survey_closed_message_heading_required: f7c48e324c4a5c335ec68eaa27b2d67e
environments/surveys/edit/survey_completed_heading: dae5ac4a02a886dc9d9fc40927091919
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "Unternehmensfunktionen",
"get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.",
"keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.",
"license_feature_access_control": "Zugriffskontrolle (RBAC)",
"license_feature_audit_logs": "Audit-Protokolle",
"license_feature_contacts": "Kontakte & Segmente",
"license_feature_projects": "Arbeitsbereiche",
"license_feature_quotas": "Kontingente",
"license_feature_remove_branding": "Branding entfernen",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spam-Schutz",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Zwei-Faktor-Authentifizierung",
"license_feature_whitelabel": "White-Label-E-Mails",
"license_features_table_access": "Zugriff",
"license_features_table_description": "Enterprise-Funktionen und Limits, die für diese Instanz aktuell verfügbar sind.",
"license_features_table_disabled": "Deaktiviert",
"license_features_table_enabled": "Aktiviert",
"license_features_table_feature": "Funktion",
"license_features_table_title": "Lizenzierte Funktionen",
"license_features_table_unlimited": "Unbegrenzt",
"license_features_table_value": "Wert",
"license_instance_mismatch_description": "Diese Lizenz ist derzeit an eine andere Formbricks-Instanz gebunden. Falls diese Installation neu aufgebaut oder verschoben wurde, bitte den Formbricks-Support, die vorherige Instanzbindung zu entfernen.",
"license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.",
"license_status": "Lizenzstatus",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "Styling auf Themenstile eingestellt",
"subheading": "Zwischenüberschrift",
"subtract": "Subtrahieren -",
"survey_closed_message_heading_required": "Füge der benutzerdefinierten Nachricht für geschlossene Umfragen eine Überschrift hinzu.",
"survey_completed_heading": "Umfrage abgeschlossen",
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "Enterprise Features",
"get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all features.",
"keep_full_control_over_your_data_privacy_and_security": "Keep full control over your data privacy and security.",
"license_feature_access_control": "Access control (RBAC)",
"license_feature_audit_logs": "Audit logs",
"license_feature_contacts": "Contacts & Segments",
"license_feature_projects": "Workspaces",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Remove branding",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spam protection",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Two-factor authentication",
"license_feature_whitelabel": "White-label emails",
"license_features_table_access": "Access",
"license_features_table_description": "Enterprise features and limits currently available to this instance.",
"license_features_table_disabled": "Disabled",
"license_features_table_enabled": "Enabled",
"license_features_table_feature": "Feature",
"license_features_table_title": "Licensed Features",
"license_features_table_unlimited": "Unlimited",
"license_features_table_value": "Value",
"license_instance_mismatch_description": "This license is currently bound to a different Formbricks instance. If this installation was rebuilt or moved, ask Formbricks support to disconnect the previous instance binding.",
"license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.",
"license_status": "License Status",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "Styling set to theme styles",
"subheading": "Subheading",
"subtract": "Subtract -",
"survey_closed_message_heading_required": "Add a heading to the custom survey closed message.",
"survey_completed_heading": "Survey Completed",
"survey_completed_subheading": "This free & open-source survey has been closed",
"survey_display_settings": "Survey Display Settings",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "Características empresariales",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtén una licencia empresarial para acceder a todas las características.",
"keep_full_control_over_your_data_privacy_and_security": "Mantén el control total sobre la privacidad y seguridad de tus datos.",
"license_feature_access_control": "Control de acceso (RBAC)",
"license_feature_audit_logs": "Registros de auditoría",
"license_feature_contacts": "Contactos y segmentos",
"license_feature_projects": "Espacios de trabajo",
"license_feature_quotas": "Cuotas",
"license_feature_remove_branding": "Eliminar marca",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Protección contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticación de dos factores",
"license_feature_whitelabel": "Correos sin marca",
"license_features_table_access": "Acceso",
"license_features_table_description": "Funciones y límites empresariales disponibles actualmente para esta instancia.",
"license_features_table_disabled": "Desactivado",
"license_features_table_enabled": "Activado",
"license_features_table_feature": "Función",
"license_features_table_title": "Funciones con licencia",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licencia está actualmente vinculada a una instancia diferente de Formbricks. Si esta instalación fue reconstruida o migrada, solicita al soporte de Formbricks que desconecte la vinculación de la instancia anterior.",
"license_invalid_description": "La clave de licencia en tu variable de entorno ENTERPRISE_LICENSE_KEY no es válida. Por favor, comprueba si hay errores tipográficos o solicita una clave nueva.",
"license_status": "Estado de la licencia",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "Estilo configurado según los estilos del tema",
"subheading": "Subtítulo",
"subtract": "Restar -",
"survey_closed_message_heading_required": "Añade un encabezado al mensaje personalizado de encuesta cerrada.",
"survey_completed_heading": "Encuesta completada",
"survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada",
"survey_display_settings": "Ajustes de visualización de la encuesta",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "Fonctionnalités d'entreprise",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenez une licence Entreprise pour accéder à toutes les fonctionnalités.",
"keep_full_control_over_your_data_privacy_and_security": "Gardez un contrôle total sur la confidentialité et la sécurité de vos données.",
"license_feature_access_control": "Contrôle d'accès (RBAC)",
"license_feature_audit_logs": "Journaux d'audit",
"license_feature_contacts": "Contacts et segments",
"license_feature_projects": "Espaces de travail",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Retirer l'image de marque",
"license_feature_saml": "SSO SAML",
"license_feature_spam_protection": "Protection anti-spam",
"license_feature_sso": "SSO OIDC",
"license_feature_two_factor_auth": "Authentification à deux facteurs",
"license_feature_whitelabel": "E-mails en marque blanche",
"license_features_table_access": "Accès",
"license_features_table_description": "Fonctionnalités Enterprise et limites actuellement disponibles pour cette instance.",
"license_features_table_disabled": "Désactivé",
"license_features_table_enabled": "Activé",
"license_features_table_feature": "Fonctionnalité",
"license_features_table_title": "Fonctionnalités sous licence",
"license_features_table_unlimited": "Illimité",
"license_features_table_value": "Valeur",
"license_instance_mismatch_description": "Cette licence est actuellement liée à une autre instance Formbricks. Si cette installation a été reconstruite ou déplacée, demande au support Formbricks de déconnecter la liaison de l'instance précédente.",
"license_invalid_description": "La clé de licence dans votre variable d'environnement ENTERPRISE_LICENSE_KEY n'est pas valide. Veuillez vérifier les fautes de frappe ou demander une nouvelle clé.",
"license_status": "Statut de la licence",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "Style défini sur les styles du thème",
"subheading": "Sous-titre",
"subtract": "Soustraire -",
"survey_closed_message_heading_required": "Ajoute un titre au message personnalisé de sondage fermé.",
"survey_completed_heading": "Enquête terminée",
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
"survey_display_settings": "Paramètres d'affichage de l'enquête",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "Vállalati funkciók",
"get_an_enterprise_license_to_get_access_to_all_features": "Vállalati licenc megszerzése az összes funkcióhoz való hozzáféréshez.",
"keep_full_control_over_your_data_privacy_and_security": "Az adatvédelem és biztonság fölötti rendelkezés teljes kézben tartása.",
"license_feature_access_control": "Hozzáférés-vezérlés (RBAC)",
"license_feature_audit_logs": "Auditálási naplók",
"license_feature_contacts": "Partnerek és szakaszok",
"license_feature_projects": "Munkaterületek",
"license_feature_quotas": "Kvóták",
"license_feature_remove_branding": "Márkajel eltávolítása",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Szemét elleni védekezés",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Kétfaktoros hitelesítés",
"license_feature_whitelabel": "Fehér címkés e-mailek",
"license_features_table_access": "Hozzáférés",
"license_features_table_description": "Az példányhoz jelenleg elérhető vállalati funkciók és korlátok.",
"license_features_table_disabled": "Letiltva",
"license_features_table_enabled": "Engedélyezve",
"license_features_table_feature": "Funkció",
"license_features_table_title": "Licencelt funkciók",
"license_features_table_unlimited": "Korlátlan",
"license_features_table_value": "Érték",
"license_instance_mismatch_description": "Ez a licenc jelenleg egy másik Formbricks-példányhoz van kötve. Ha ezt a telepítést újraépítették vagy áthelyezték, akkor kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi példányhoz való kötést.",
"license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban lévő licenckulcs nem érvényes. Ellenőrizze, hogy nem gépelte-e el, vagy kérjen új kulcsot.",
"license_status": "Licencállapot",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "A stílus a téma stílusaira állítva",
"subheading": "Alcím",
"subtract": "Kivonás -",
"survey_closed_message_heading_required": "Címsor hozzáadása az egyéni kérdőív záró üzenetéhez.",
"survey_completed_heading": "A kérdőív kitöltve",
"survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva",
"survey_display_settings": "Kérdőív megjelenítésének beállításai",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "エンタープライズ機能",
"get_an_enterprise_license_to_get_access_to_all_features": "すべての機能にアクセスするには、エンタープライズライセンスを取得してください。",
"keep_full_control_over_your_data_privacy_and_security": "データのプライバシーとセキュリティを完全に制御できます。",
"license_feature_access_control": "アクセス制御RBAC",
"license_feature_audit_logs": "監査ログ",
"license_feature_contacts": "連絡先とセグメント",
"license_feature_projects": "ワークスペース",
"license_feature_quotas": "クォータ",
"license_feature_remove_branding": "ブランディングの削除",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "スパム保護",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "二要素認証",
"license_feature_whitelabel": "ホワイトラベルメール",
"license_features_table_access": "アクセス",
"license_features_table_description": "このインスタンスで現在利用可能なエンタープライズ機能と制限。",
"license_features_table_disabled": "無効",
"license_features_table_enabled": "有効",
"license_features_table_feature": "機能",
"license_features_table_title": "ライセンス機能",
"license_features_table_unlimited": "無制限",
"license_features_table_value": "値",
"license_instance_mismatch_description": "このライセンスは現在、別のFormbricksインスタンスに紐付けられています。このインストールが再構築または移動された場合は、Formbricksサポートに連絡して、以前のインスタンスの紐付けを解除してもらってください。",
"license_invalid_description": "ENTERPRISE_LICENSE_KEY環境変数のライセンスキーが無効です。入力ミスがないか確認するか、新しいキーをリクエストしてください。",
"license_status": "ライセンスステータス",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "スタイルをテーマのスタイルに設定しました",
"subheading": "サブ見出し",
"subtract": "減算 -",
"survey_closed_message_heading_required": "カスタムアンケート終了メッセージに見出しを追加してください。",
"survey_completed_heading": "フォームが完了しました",
"survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました",
"survey_display_settings": "フォーム表示設定",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "Enterprise-functies",
"get_an_enterprise_license_to_get_access_to_all_features": "Ontvang een Enterprise-licentie om toegang te krijgen tot alle functies.",
"keep_full_control_over_your_data_privacy_and_security": "Houd de volledige controle over de privacy en beveiliging van uw gegevens.",
"license_feature_access_control": "Toegangscontrole (RBAC)",
"license_feature_audit_logs": "Auditlogboeken",
"license_feature_contacts": "Contacten & Segmenten",
"license_feature_projects": "Werkruimtes",
"license_feature_quotas": "Quota's",
"license_feature_remove_branding": "Branding verwijderen",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spambescherming",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Tweefactorauthenticatie",
"license_feature_whitelabel": "Whitelabel-e-mails",
"license_features_table_access": "Toegang",
"license_features_table_description": "Enterprise-functies en limieten die momenteel beschikbaar zijn voor deze instantie.",
"license_features_table_disabled": "Uitgeschakeld",
"license_features_table_enabled": "Ingeschakeld",
"license_features_table_feature": "Functie",
"license_features_table_title": "Gelicentieerde Functies",
"license_features_table_unlimited": "Onbeperkt",
"license_features_table_value": "Waarde",
"license_instance_mismatch_description": "Deze licentie is momenteel gekoppeld aan een andere Formbricks-instantie. Als deze installatie is herbouwd of verplaatst, vraag dan Formbricks-support om de vorige instantiekoppeling te verbreken.",
"license_invalid_description": "De licentiesleutel in je ENTERPRISE_LICENSE_KEY omgevingsvariabele is niet geldig. Controleer op typefouten of vraag een nieuwe sleutel aan.",
"license_status": "Licentiestatus",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "Styling ingesteld op themastijlen",
"subheading": "Ondertitel",
"subtract": "Aftrekken -",
"survey_closed_message_heading_required": "Voeg een kop toe aan het aangepaste bericht voor gesloten enquêtes.",
"survey_completed_heading": "Enquête voltooid",
"survey_completed_subheading": "Deze gratis en open source-enquête is gesloten",
"survey_display_settings": "Enquêteweergave-instellingen",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "Recursos Empresariais",
"get_an_enterprise_license_to_get_access_to_all_features": "Adquira uma licença Enterprise para ter acesso a todos os recursos.",
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controle total sobre a privacidade e segurança dos seus dados.",
"license_feature_access_control": "Controle de acesso (RBAC)",
"license_feature_audit_logs": "Logs de auditoria",
"license_feature_contacts": "Contatos e Segmentos",
"license_feature_projects": "Workspaces",
"license_feature_quotas": "Cotas",
"license_feature_remove_branding": "Remover identidade visual",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Proteção contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticação de dois fatores",
"license_feature_whitelabel": "E-mails white-label",
"license_features_table_access": "Acesso",
"license_features_table_description": "Recursos empresariais e limites disponíveis atualmente para esta instância.",
"license_features_table_disabled": "Desabilitado",
"license_features_table_enabled": "Habilitado",
"license_features_table_feature": "Recurso",
"license_features_table_title": "Recursos Licenciados",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licença está atualmente vinculada a uma instância diferente do Formbricks. Se esta instalação foi reconstruída ou movida, peça ao suporte do Formbricks para desconectar a vinculação da instância anterior.",
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Verifique se há erros de digitação ou solicite uma nova chave.",
"license_status": "Status da licença",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "Estilo definido para os estilos do tema",
"subheading": "Subtítulo",
"subtract": "Subtrair -",
"survey_closed_message_heading_required": "Adicione um título à mensagem personalizada de pesquisa encerrada.",
"survey_completed_heading": "Pesquisa Concluída",
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
"survey_display_settings": "Configurações de Exibição da Pesquisa",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "Funcionalidades da Empresa",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenha uma licença Enterprise para ter acesso a todas as funcionalidades.",
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controlo total sobre a privacidade e segurança dos seus dados.",
"license_feature_access_control": "Controlo de acesso (RBAC)",
"license_feature_audit_logs": "Registos de auditoria",
"license_feature_contacts": "Contactos e Segmentos",
"license_feature_projects": "Áreas de trabalho",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Remover marca",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Proteção contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticação de dois fatores",
"license_feature_whitelabel": "E-mails personalizados",
"license_features_table_access": "Acesso",
"license_features_table_description": "Funcionalidades e limites empresariais atualmente disponíveis para esta instância.",
"license_features_table_disabled": "Desativado",
"license_features_table_enabled": "Ativado",
"license_features_table_feature": "Funcionalidade",
"license_features_table_title": "Funcionalidades Licenciadas",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licença está atualmente associada a uma instância Formbricks diferente. Se esta instalação foi reconstruída ou movida, pede ao suporte da Formbricks para desconectar a associação da instância anterior.",
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Por favor, verifique se existem erros de digitação ou solicite uma nova chave.",
"license_status": "Estado da licença",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "Estilo definido para estilos do tema",
"subheading": "Subtítulo",
"subtract": "Subtrair -",
"survey_closed_message_heading_required": "Adiciona um título à mensagem personalizada de inquérito encerrado.",
"survey_completed_heading": "Inquérito Concluído",
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
"survey_display_settings": "Configurações de Exibição do Inquérito",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "Funcții Enterprise",
"get_an_enterprise_license_to_get_access_to_all_features": "Obțineți o licență Enterprise pentru a avea acces la toate funcționalitățile.",
"keep_full_control_over_your_data_privacy_and_security": "Mențineți controlul complet asupra confidențialității și securității datelor dumneavoastră.",
"license_feature_access_control": "Control acces (RBAC)",
"license_feature_audit_logs": "Jurnale de audit",
"license_feature_contacts": "Contacte și segmente",
"license_feature_projects": "Spații de lucru",
"license_feature_quotas": "Cote",
"license_feature_remove_branding": "Elimină branding-ul",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Protecție spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autentificare cu doi factori",
"license_feature_whitelabel": "E-mailuri white-label",
"license_features_table_access": "Acces",
"license_features_table_description": "Funcționalități și limite enterprise disponibile în prezent pentru această instanță.",
"license_features_table_disabled": "Dezactivat",
"license_features_table_enabled": "Activat",
"license_features_table_feature": "Funcționalitate",
"license_features_table_title": "Funcționalități licențiate",
"license_features_table_unlimited": "Nelimitat",
"license_features_table_value": "Valoare",
"license_instance_mismatch_description": "Această licență este în prezent asociată cu o altă instanță Formbricks. Dacă această instalare a fost reconstruită sau mutată, solicită echipei de suport Formbricks să deconecteze asocierea cu instanța anterioară.",
"license_invalid_description": "Cheia de licență din variabila de mediu ENTERPRISE_LICENSE_KEY nu este validă. Te rugăm să verifici dacă există greșeli de scriere sau să soliciți o cheie nouă.",
"license_status": "Stare licență",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "Stilizare setată la stilurile temei",
"subheading": "Subtitlu",
"subtract": "Scade -",
"survey_closed_message_heading_required": "Adaugă un titlu la mesajul personalizat pentru sondajul închis.",
"survey_completed_heading": "Sondaj Completat",
"survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis",
"survey_display_settings": "Setări de afișare a sondajului",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "Функции для предприятий",
"get_an_enterprise_license_to_get_access_to_all_features": "Получите корпоративную лицензию для доступа ко всем функциям.",
"keep_full_control_over_your_data_privacy_and_security": "Полный контроль над конфиденциальностью и безопасностью ваших данных.",
"license_feature_access_control": "Управление доступом (RBAC)",
"license_feature_audit_logs": "Журналы аудита",
"license_feature_contacts": "Контакты и сегменты",
"license_feature_projects": "Рабочие пространства",
"license_feature_quotas": "Квоты",
"license_feature_remove_branding": "Удаление брендирования",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Защита от спама",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Двухфакторная аутентификация",
"license_feature_whitelabel": "Электронные письма без брендирования",
"license_features_table_access": "Доступ",
"license_features_table_description": "Корпоративные функции и ограничения, доступные для этого экземпляра.",
"license_features_table_disabled": "Отключено",
"license_features_table_enabled": "Включено",
"license_features_table_feature": "Функция",
"license_features_table_title": "Лицензированные функции",
"license_features_table_unlimited": "Без ограничений",
"license_features_table_value": "Значение",
"license_instance_mismatch_description": "Эта лицензия в данный момент привязана к другому экземпляру Formbricks. Если эта установка была пересобрана или перемещена, обратитесь в службу поддержки Formbricks для отключения предыдущей привязки экземпляра.",
"license_invalid_description": "Ключ лицензии в переменной окружения ENTERPRISE_LICENSE_KEY недействителен. Проверь, нет ли опечаток, или запроси новый ключ.",
"license_status": "Статус лицензии",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "Оформление установлено в соответствии с темой",
"subheading": "Подзаголовок",
"subtract": "Вычесть -",
"survey_closed_message_heading_required": "Добавьте заголовок к сообщению о закрытом опросе.",
"survey_completed_heading": "Опрос завершён",
"survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт",
"survey_display_settings": "Настройки отображения опроса",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "Enterprise-funktioner",
"get_an_enterprise_license_to_get_access_to_all_features": "Skaffa en Enterprise-licens för att få tillgång till alla funktioner.",
"keep_full_control_over_your_data_privacy_and_security": "Behåll full kontroll över din datasekretess och säkerhet.",
"license_feature_access_control": "Åtkomstkontroll (RBAC)",
"license_feature_audit_logs": "Granskningsloggar",
"license_feature_contacts": "Kontakter & Segment",
"license_feature_projects": "Arbetsytor",
"license_feature_quotas": "Kvoter",
"license_feature_remove_branding": "Ta bort varumärkning",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Skräppostskydd",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Tvåfaktorsautentisering",
"license_feature_whitelabel": "White-label-mejl",
"license_features_table_access": "Åtkomst",
"license_features_table_description": "Företagsfunktioner och begränsningar som för närvarande är tillgängliga för den här instansen.",
"license_features_table_disabled": "Inaktiverad",
"license_features_table_enabled": "Aktiverad",
"license_features_table_feature": "Funktion",
"license_features_table_title": "Licensierade funktioner",
"license_features_table_unlimited": "Obegränsad",
"license_features_table_value": "Värde",
"license_instance_mismatch_description": "Den här licensen är för närvarande kopplad till en annan Formbricks-instans. Om den här installationen har återuppbyggts eller flyttats, be Formbricks support att koppla bort den tidigare instansbindningen.",
"license_invalid_description": "Licensnyckeln i din ENTERPRISE_LICENSE_KEY-miljövariabel är ogiltig. Kontrollera om det finns stavfel eller begär en ny nyckel.",
"license_status": "Licensstatus",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "Styling inställd på temastil",
"subheading": "Underrubrik",
"subtract": "Subtrahera -",
"survey_closed_message_heading_required": "Lägg till en rubrik för det anpassade meddelandet när undersökningen är stängd.",
"survey_completed_heading": "Enkät slutförd",
"survey_completed_subheading": "Denna gratis och öppenkällkodsenkät har stängts",
"survey_display_settings": "Visningsinställningar för enkät",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "企业 功能",
"get_an_enterprise_license_to_get_access_to_all_features": "获取 企业 许可证 来 访问 所有 功能。",
"keep_full_control_over_your_data_privacy_and_security": "保持 对 您 的 数据 隐私 和 安全 的 完全 控制。",
"license_feature_access_control": "访问控制RBAC",
"license_feature_audit_logs": "审计日志",
"license_feature_contacts": "联系人与细分",
"license_feature_projects": "工作空间",
"license_feature_quotas": "配额",
"license_feature_remove_branding": "移除品牌标识",
"license_feature_saml": "SAML 单点登录",
"license_feature_spam_protection": "垃圾信息防护",
"license_feature_sso": "OIDC 单点登录",
"license_feature_two_factor_auth": "双因素认证",
"license_feature_whitelabel": "白标电子邮件",
"license_features_table_access": "访问权限",
"license_features_table_description": "此实例当前可用的企业功能和限制。",
"license_features_table_disabled": "已禁用",
"license_features_table_enabled": "已启用",
"license_features_table_feature": "功能",
"license_features_table_title": "许可功能",
"license_features_table_unlimited": "无限制",
"license_features_table_value": "值",
"license_instance_mismatch_description": "此许可证目前绑定到另一个 Formbricks 实例。如果此安装已重建或迁移,请联系 Formbricks 支持团队解除先前的实例绑定。",
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 环境变量中填写的许可证密钥无效。请检查是否有拼写错误,或者申请一个新的密钥。",
"license_status": "许可证状态",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "样式 设置 为 主题 风格",
"subheading": "子标题",
"subtract": "减 -",
"survey_closed_message_heading_required": "请为自定义调查关闭消息添加标题。",
"survey_completed_heading": "调查 完成",
"survey_completed_subheading": "此 免费 & 开源 调查 已 关闭",
"survey_display_settings": "调查显示设置",

View File

@@ -1073,6 +1073,25 @@
"enterprise_features": "企業版功能",
"get_an_enterprise_license_to_get_access_to_all_features": "取得企業授權以存取所有功能。",
"keep_full_control_over_your_data_privacy_and_security": "完全掌控您的資料隱私權和安全性。",
"license_feature_access_control": "存取控制 (RBAC)",
"license_feature_audit_logs": "稽核日誌",
"license_feature_contacts": "聯絡人與區隔",
"license_feature_projects": "工作區",
"license_feature_quotas": "配額",
"license_feature_remove_branding": "移除品牌標識",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "垃圾訊息防護",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "雙重驗證",
"license_feature_whitelabel": "白標電子郵件",
"license_features_table_access": "存取權限",
"license_features_table_description": "此執行個體目前可使用的企業功能與限制。",
"license_features_table_disabled": "已停用",
"license_features_table_enabled": "已啟用",
"license_features_table_feature": "功能",
"license_features_table_title": "授權功能",
"license_features_table_unlimited": "無限制",
"license_features_table_value": "值",
"license_instance_mismatch_description": "此授權目前綁定至不同的 Formbricks 執行個體。如果此安裝已重建或移動,請聯繫 Formbricks 支援以解除先前執行個體的綁定。",
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 環境變數中填寫的授權金鑰無效。請檢查是否有輸入錯誤,或申請新的金鑰。",
"license_status": "授權狀態",
@@ -1702,6 +1721,7 @@
"styling_set_to_theme_styles": "樣式設定為主題樣式",
"subheading": "副標題",
"subtract": "減 -",
"survey_closed_message_heading_required": "請為自訂的問卷關閉訊息新增標題。",
"survey_completed_heading": "問卷已完成",
"survey_completed_subheading": "此免費且開源的問卷已關閉",
"survey_display_settings": "問卷顯示設定",

View File

@@ -217,7 +217,7 @@ describe("utils", () => {
});
describe("logApiError", () => {
test("logs API error details", () => {
test("logs API error details with method and path", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
@@ -228,7 +228,7 @@ describe("utils", () => {
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test");
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "POST" });
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
@@ -238,9 +238,11 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context
// Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123",
method: "POST",
path: "/api/v2/management/surveys",
error,
});
@@ -275,6 +277,8 @@ describe("utils", () => {
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "",
method: "GET",
path: "/api/test",
error,
});
@@ -285,7 +289,7 @@ describe("utils", () => {
logger.withContext = originalWithContext;
});
test("log API error details with SENTRY_DSN set", () => {
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
@@ -295,11 +299,23 @@ describe("utils", () => {
// Mock Sentry's captureException method
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
// Capture the scope mock for tag verification
const scopeSetTagMock = vi.fn();
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
const mockScope = {
setTag: scopeSetTagMock,
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test");
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "DELETE" });
mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = {
@@ -309,20 +325,60 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context
// Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
error,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Verify Sentry scope tags include method and path
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();
// Restore the original method
logger.withContext = originalWithContext;
});
test("does not send to Sentry for non-internal_server_error types", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
vi.mocked(Sentry.captureException).mockClear();
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/v2/management/surveys");
mockRequest.headers.set("x-request-id", "456");
const error: ApiErrorResponseV2 = {
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
};
logApiError(mockRequest, error);
// Verify Sentry.captureException was NOT called for non-500 errors
expect(Sentry.captureException).not.toHaveBeenCalled();
// But structured logging should still happen
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
});
});

View File

@@ -6,13 +6,18 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
// 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
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setTag("method", method);
scope.setTag("path", path);
scope.setLevel("error");
scope.setExtra("originalError", error);
@@ -24,6 +29,8 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
logger
.withContext({
correlationId,
method,
path,
error,
})
.error("API V2 Error Details");

View File

@@ -30,16 +30,16 @@ export const ContactsSecondaryNavigation = async ({
label: t("common.contacts"),
href: `/environments/${environmentId}/contacts`,
},
{
id: "segments",
label: t("common.segments"),
href: `/environments/${environmentId}/segments`,
},
{
id: "attributes",
label: t("common.attributes"),
href: `/environments/${environmentId}/attributes`,
},
{
id: "segments",
label: t("common.segments"),
href: `/environments/${environmentId}/segments`,
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;

View File

@@ -97,14 +97,13 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
);
const ZUpdateSegmentAction = z.object({
environmentId: ZId,
segmentId: ZId,
data: ZSegmentUpdateInput,
});
export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action(
withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,

View File

@@ -75,7 +75,6 @@ export function SegmentSettings({
try {
setIsUpdatingSegment(true);
const data = await updateSegmentAction({
environmentId,
segmentId: segment.id,
data: {
title: segment.title,
@@ -134,6 +133,10 @@ export function SegmentSettings({
return true;
}
if (segment.filters.length === 0) {
return true;
}
// parse the filters to check if they are valid
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) {

View File

@@ -124,7 +124,7 @@ export function TargetingCard({
};
const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => {
const updatedSegment = await updateSegmentAction({ segmentId, environmentId, data });
const updatedSegment = await updateSegmentAction({ segmentId, data });
return updatedSegment?.data as TSegment;
};
@@ -136,7 +136,7 @@ export function TargetingCard({
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
try {
if (!segment) throw new Error(t("environments.segments.invalid_segment"));
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
const result = await updateSegmentAction({ segmentId: segment.id, data });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;

View File

@@ -0,0 +1,73 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
const validFilters = [
{
id: createId(),
connector: null,
resource: {
id: createId(),
root: {
type: "attribute" as const,
contactAttributeKey: "email",
},
value: "user@example.com",
qualifier: {
operator: "equals" as const,
},
},
},
];
describe("segment schema validation", () => {
test("keeps base segment filters compatible with empty arrays", () => {
const result = ZSegmentFilters.safeParse([]);
expect(result.success).toBe(true);
});
test("requires at least one filter when creating a segment", () => {
const result = ZSegmentCreateInput.safeParse({
environmentId: "environmentId",
title: "Power users",
description: "Users with a matching email",
isPrivate: false,
filters: [],
surveyId: "surveyId",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.message).toBe("At least one filter is required");
});
test("accepts segment creation with a valid filter", () => {
const result = ZSegmentCreateInput.safeParse({
environmentId: "environmentId",
title: "Power users",
description: "Users with a matching email",
isPrivate: false,
filters: validFilters,
surveyId: "surveyId",
});
expect(result.success).toBe(true);
});
test("requires at least one filter when updating a segment", () => {
const result = ZSegmentUpdateInput.safeParse({
filters: [],
});
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.message).toBe("At least one filter is required");
});
test("accepts segment updates with a valid filter", () => {
const result = ZSegmentUpdateInput.safeParse({
filters: validFilters,
});
expect(result.success).toBe(true);
});
});

View File

@@ -21,7 +21,6 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZDeleteQuotaAction = z.object({
quotaId: ZId,
surveyId: ZId,
});
const checkQuotasEnabled = async (organizationId: string) => {
@@ -37,7 +36,7 @@ const checkQuotasEnabled = async (organizationId: string) => {
export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQuotaAction).action(
withAuditLogging("deleted", "quota", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -49,7 +48,7 @@ export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQu
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
minPermission: "readWrite",
},
],
@@ -72,7 +71,7 @@ const ZUpdateQuotaAction = z.object({
export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQuotaAction).action(
withAuditLogging("updated", "quota", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId);
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -84,7 +83,7 @@ export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQu
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.quota.surveyId),
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
minPermission: "readWrite",
},
],

View File

@@ -85,7 +85,6 @@ export const QuotasCard = ({
setIsDeletingQuota(true);
const deleteQuotaActionResult = await deleteQuotaAction({
quotaId: quotaId,
surveyId: localSurvey.id,
});
if (deleteQuotaActionResult?.data) {
toast.success(t("environments.surveys.edit.quotas.quota_deleted_successfull_toast"));

View File

@@ -10,6 +10,7 @@ import { getUserManagementAccess } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
@@ -31,7 +32,6 @@ export const checkRoleManagementPermission = async (organizationId: string) => {
const ZUpdateInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
data: ZInviteUpdateInput,
});
@@ -39,17 +39,16 @@ export type TUpdateInviteAction = z.infer<typeof ZUpdateInviteAction>;
export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateInviteAction).action(
withAuditLogging("updated", "invite", async ({ ctx, parsedInput }) => {
const currentUserMembership = await getMembershipByUserIdOrganizationId(
ctx.user.id,
parsedInput.organizationId
);
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organizationId);
if (!currentUserMembership) {
throw new AuthenticationError("User not a member of this organization");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
organizationId,
access: [
{
data: parsedInput.data,
@@ -68,9 +67,9 @@ export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateI
throw new OperationNotAllowedError("Managers can only invite members");
}
await checkRoleManagementPermission(parsedInput.organizationId);
await checkRoleManagementPermission(organizationId);
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };

View File

@@ -65,7 +65,7 @@ export function EditMembershipRole({
}
if (inviteId) {
await updateInviteAction({ inviteId: inviteId, organizationId, data: { role } });
await updateInviteAction({ inviteId: inviteId, data: { role } });
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));

View File

@@ -27,14 +27,15 @@ import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInv
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
organizationId: ZId,
});
export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteInviteAction).action(
withAuditLogging("deleted", "invite", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
organizationId,
access: [
{
type: "organization",
@@ -42,7 +43,7 @@ export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteI
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
return await deleteInvite(parsedInput.inviteId);

View File

@@ -41,7 +41,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
if (!member && invite) {
// This is an invite
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
const result = await deleteInviteAction({ inviteId: invite?.id });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
setIsDeleting(false);

View File

@@ -89,7 +89,7 @@ export const InviteMemberModal = ({
<DialogDescription>{t("environments.settings.teams.invite_member_description")}</DialogDescription>
</DialogHeader>
<DialogBody className="flex flex-col gap-6" unconstrained>
<DialogBody className="flex min-h-0 flex-col gap-6 overflow-y-auto">
{!showTeamAdminRestrictions && (
<TabToggle
id="type"

View File

@@ -38,6 +38,7 @@ export const ActionSettingsTab = ({
setOpen,
isReadOnly,
}: ActionSettingsTabProps) => {
const actionDocsHref = "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions";
const { createdAt, updatedAt, id, ...restActionClass } = actionClass;
const router = useRouter();
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
@@ -146,7 +147,7 @@ export const ActionSettingsTab = ({
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
<div className="flex items-center gap-x-2">
{!isReadOnly ? (
{isReadOnly ? null : (
<Button
type="button"
variant="destructive"
@@ -155,22 +156,22 @@ export const ActionSettingsTab = ({
<TrashIcon />
{t("common.delete")}
</Button>
) : null}
)}
<Button variant="secondary" asChild>
<Link href="https://formbricks.com/docs/actions/no-code" target="_blank">
<Link href={actionDocsHref} target="_blank">
{t("common.read_docs")}
</Link>
</Button>
</div>
{!isReadOnly ? (
{isReadOnly ? null : (
<div className="flex space-x-2">
<Button type="submit" loading={isUpdatingAction}>
{t("common.save_changes")}
</Button>
</div>
) : null}
)}
</div>
</form>
</FormProvider>

View File

@@ -4,7 +4,7 @@
import "@testing-library/jest-dom/vitest";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import type { z } from "zod";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import {
createActionClassZodResolver,

View File

@@ -316,10 +316,6 @@ describe("validation.isEndingCardValid", () => {
const card = { ...baseRedirectUrlCard, label: " " };
expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
});
// test("should return false for redirectUrl card if label is undefined", () => {
// const card = { ...baseRedirectUrlCard, label: undefined };
// expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
// });
});
describe("validation.validateElement", () => {
@@ -1029,6 +1025,66 @@ describe("validation.isSurveyValid", () => {
expect(toast.error).not.toHaveBeenCalled();
});
test("should return false and toast error if a link survey has an empty custom survey closed message heading", () => {
const surveyWithEmptyClosedMessageHeading = {
...baseSurvey,
type: "link",
surveyClosedMessage: {
heading: "",
subheading: "Closed for now",
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithEmptyClosedMessageHeading, "en", mockT)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.survey_closed_message_heading_required"
);
});
test("should return false and toast error if a link survey has a whitespace-only custom survey closed message heading", () => {
const surveyWithWhitespaceClosedMessageHeading = {
...baseSurvey,
type: "link",
surveyClosedMessage: {
heading: " ",
subheading: "",
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithWhitespaceClosedMessageHeading, "en", mockT)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.survey_closed_message_heading_required"
);
});
test("should return true if a link survey has a custom survey closed message heading and no subheading", () => {
const surveyWithHeadingOnlyClosedMessage = {
...baseSurvey,
type: "link",
surveyClosedMessage: {
heading: "Survey closed",
subheading: "",
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithHeadingOnlyClosedMessage, "en", mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
test("should return true if a link survey has a custom survey closed message heading and subheading", () => {
const surveyWithClosedMessageContent = {
...baseSurvey,
type: "link",
surveyClosedMessage: {
heading: "Survey closed",
subheading: "Thanks for your interest",
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithClosedMessageContent, "en", mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
describe("App Survey Segment Validation", () => {
test("should return false and toast error for app survey with invalid segment filters", () => {
const surveyWithInvalidSegment = {

View File

@@ -151,11 +151,7 @@ export const validationRules = {
for (const field of fieldsToValidate) {
const fieldValue = (element as unknown as Record<string, Record<string, string> | undefined>)[field];
if (
fieldValue &&
typeof fieldValue[defaultLanguageCode] !== "undefined" &&
fieldValue[defaultLanguageCode].trim() !== ""
) {
if (fieldValue?.[defaultLanguageCode] !== undefined && fieldValue[defaultLanguageCode].trim() !== "") {
isValid = isValid && isLabelValidForAllLanguages(fieldValue, languages);
}
}
@@ -203,6 +199,16 @@ const isContentValid = (content: Record<string, string> | undefined, surveyLangu
return !content || isLabelValidForAllLanguages(content, surveyLanguages);
};
const hasValidSurveyClosedMessageHeading = (survey: TSurvey): boolean => {
if (survey.type !== "link" || !survey.surveyClosedMessage) {
return true;
}
const heading = survey.surveyClosedMessage.heading?.trim() ?? "";
return heading.length > 0;
};
export const isWelcomeCardValid = (card: TSurveyWelcomeCard, surveyLanguages: TSurveyLanguage[]): boolean => {
return isContentValid(card.headline, surveyLanguages) && isContentValid(card.subheader, surveyLanguages);
};
@@ -286,5 +292,10 @@ export const isSurveyValid = (
}
}
if (!hasValidSurveyClosedMessageHeading(survey)) {
toast.error(t("environments.surveys.edit.survey_closed_message_heading_required"));
return false;
}
return true;
};

View File

@@ -50,7 +50,11 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
formbricks.track(&quot;{watch("key")}&quot;)
</span>{" "}
{t("environments.actions.in_your_code_read_more_in_our")}{" "}
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
<a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
target="_blank"
rel="noreferrer"
className="underline">
{t("common.docs")}
</a>
{"."}

View File

@@ -43,6 +43,17 @@ vi.mock("@/lib/common/utils", () => ({
handleHiddenFields: vi.fn(),
}));
const mockUpdateQueue = {
hasPendingWork: vi.fn().mockReturnValue(false),
waitForPendingWork: vi.fn().mockResolvedValue(true),
};
vi.mock("@/lib/user/update-queue", () => ({
UpdateQueue: {
getInstance: vi.fn(() => mockUpdateQueue),
},
}));
describe("widget-file", () => {
let getInstanceConfigMock: MockInstance<() => Config>;
let getInstanceLoggerMock: MockInstance<() => Logger>;
@@ -249,4 +260,265 @@ describe("widget-file", () => {
widget.removeWidgetContainer();
expect(document.getElementById("formbricks-container")).toBeFalsy();
});
test("renderWidget waits for pending identification before rendering", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_abc",
})
);
vi.useRealTimers();
});
test("renderWidget does not wait when no identification is pending", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: "user_abc",
contactId: "contact_abc",
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
expect(mockUpdateQueue.waitForPendingWork).not.toHaveBeenCalled();
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
test("renderWidget reads contactId after identification wait completes", async () => {
let callCount = 0;
const mockConfigValue = {
get: vi.fn().mockImplementation(() => {
callCount++;
return {
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
// Simulate contactId becoming available after identification
userId: "user_abc",
contactId: callCount > 2 ? "contact_after_identification" : undefined,
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
};
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
} as unknown as TEnvironmentStateSurvey);
vi.advanceTimersByTime(0);
// The contactId passed to renderSurvey should be read after the wait
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
expect.objectContaining({
contactId: "contact_after_identification",
})
);
vi.useRealTimers();
});
test("renderWidget skips survey when identification fails and survey has segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
await widget.renderWidget({
...mockSurvey,
delay: 0,
segment: { id: "seg_1", filters: [{ type: "attribute", value: "plan" }] },
} as unknown as TEnvironmentStateSurvey);
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith(
"User identification failed. Skipping survey with segment filters."
);
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
});
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
const mockConfigValue = {
get: vi.fn().mockReturnValue({
appUrl: "https://fake.app",
environmentId: "env_123",
environment: {
data: {
project: {
clickOutsideClose: true,
overlay: "none",
placement: "bottomRight",
inAppSurveyBranding: true,
},
},
},
user: {
data: {
userId: null,
contactId: null,
displays: [],
responses: [],
lastDisplayAt: null,
language: "en",
},
},
}),
update: vi.fn(),
};
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
widget.setIsSurveyRunning(false);
// @ts-expect-error -- mock window.formbricksSurveys
window.formbricksSurveys = {
renderSurvey: vi.fn(),
};
vi.useFakeTimers();
await widget.renderWidget({
...mockSurvey,
delay: 0,
segment: undefined,
} as unknown as TEnvironmentStateSurvey);
expect(mockLogger.debug).toHaveBeenCalledWith(
"User identification failed but survey has no segment filters. Proceeding."
);
vi.advanceTimersByTime(0);
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
vi.useRealTimers();
});
});

View File

@@ -11,6 +11,7 @@ import {
handleHiddenFields,
shouldDisplayBasedOnPercentage,
} from "@/lib/common/utils";
import { UpdateQueue } from "@/lib/user/update-queue";
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
import { type TTrackProperties } from "@/types/survey";
@@ -60,6 +61,24 @@ export const renderWidget = async (
setIsSurveyRunning(true);
// Wait for pending user identification to complete before rendering
const updateQueue = UpdateQueue.getInstance();
if (updateQueue.hasPendingWork()) {
logger.debug("Waiting for pending user identification before rendering survey");
const identificationSucceeded = await updateQueue.waitForPendingWork();
if (!identificationSucceeded) {
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
if (hasSegmentFilters) {
logger.debug("User identification failed. Skipping survey with segment filters.");
setIsSurveyRunning(false);
return;
}
logger.debug("User identification failed but survey has no segment filters. Proceeding.");
}
}
if (survey.delay) {
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
}

View File

@@ -169,4 +169,104 @@ describe("UpdateQueue", () => {
"Formbricks can't set attributes without a userId! Please set a userId first with the setUserId function"
);
});
test("hasPendingWork returns false when no updates and no flush in flight", () => {
expect(updateQueue.hasPendingWork()).toBe(false);
});
test("hasPendingWork returns true when updates are queued", () => {
updateQueue.updateUserId(mockUserId1);
expect(updateQueue.hasPendingWork()).toBe(true);
});
test("hasPendingWork returns true while processUpdates flush is in flight", () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
// Start processing but don't await — the debounce means the flush is in-flight
void updateQueue.processUpdates();
expect(updateQueue.hasPendingWork()).toBe(true);
});
test("waitForPendingWork returns true immediately when no pending work", async () => {
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(true);
});
test("waitForPendingWork returns true when processUpdates succeeds", async () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
void updateQueue.processUpdates();
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(true);
expect(updateQueue.hasPendingWork()).toBe(false);
expect(sendUpdates).toHaveBeenCalled();
});
test("waitForPendingWork returns false when processUpdates rejects", async () => {
loggerMock.mockReturnValue(mockLogger as unknown as Logger);
(sendUpdates as Mock).mockRejectedValue(new Error("network error"));
updateQueue.updateUserId(mockUserId1);
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally swallowing rejection to avoid unhandled promise
const processPromise = updateQueue.processUpdates().catch(() => {});
const result = await updateQueue.waitForPendingWork();
expect(result).toBe(false);
await processPromise;
});
test("waitForPendingWork returns false when flush hangs past timeout", async () => {
vi.useFakeTimers();
// sendUpdates returns a promise that never resolves, simulating a network hang
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise
(sendUpdates as Mock).mockReturnValue(new Promise(() => {}));
updateQueue.updateUserId(mockUserId1);
void updateQueue.processUpdates();
const resultPromise = updateQueue.waitForPendingWork();
// Advance past the debounce delay (500ms) so the handler fires and hangs on sendUpdates
await vi.advanceTimersByTimeAsync(500);
// Advance past the pending work timeout (5000ms)
await vi.advanceTimersByTimeAsync(5000);
const result = await resultPromise;
expect(result).toBe(false);
vi.useRealTimers();
});
test("processUpdates reuses pending flush instead of creating orphaned promises", async () => {
(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});
updateQueue.updateUserId(mockUserId1);
// First call creates the flush promise
const firstPromise = updateQueue.processUpdates();
// Second call while first is still pending should not create a new flush
updateQueue.updateAttributes({ name: mockAttributes.name });
const secondPromise = updateQueue.processUpdates();
// Both promises should resolve (second is not orphaned)
await Promise.all([firstPromise, secondPromise]);
expect(updateQueue.hasPendingWork()).toBe(false);
});
});

View File

@@ -8,7 +8,9 @@ export class UpdateQueue {
private static instance: UpdateQueue | null = null;
private updates: TUpdates | null = null;
private debounceTimeout: NodeJS.Timeout | null = null;
private pendingFlush: Promise<void> | null = null;
private readonly DEBOUNCE_DELAY = 500;
private readonly PENDING_WORK_TIMEOUT = 5000;
private constructor() {}
@@ -63,17 +65,45 @@ export class UpdateQueue {
return !this.updates;
}
public hasPendingWork(): boolean {
return this.updates !== null || this.pendingFlush !== null;
}
public async waitForPendingWork(): Promise<boolean> {
if (!this.hasPendingWork()) return true;
const flush = this.pendingFlush ?? this.processUpdates();
try {
const succeeded = await Promise.race([
flush.then(() => true as const),
new Promise<false>((resolve) => {
setTimeout(() => {
resolve(false);
}, this.PENDING_WORK_TIMEOUT);
}),
]);
return succeeded;
} catch {
return false;
}
}
public async processUpdates(): Promise<void> {
const logger = Logger.getInstance();
if (!this.updates) {
return;
}
// If a flush is already in flight, reuse it instead of creating a new promise
if (this.pendingFlush) {
return this.pendingFlush;
}
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout);
}
return new Promise((resolve, reject) => {
const flushPromise = new Promise<void>((resolve, reject) => {
const handler = async (): Promise<void> => {
try {
let currentUpdates = { ...this.updates };
@@ -147,8 +177,10 @@ export class UpdateQueue {
}
this.clearUpdates();
this.pendingFlush = null;
resolve();
} catch (error: unknown) {
this.pendingFlush = null;
logger.error(
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
);
@@ -158,5 +190,8 @@ export class UpdateQueue {
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
});
this.pendingFlush = flushPromise;
return flushPromise;
}
}

View File

@@ -45,6 +45,8 @@ const baseLoggerConfig: LoggerOptions = {
* - Both: optional pino-opentelemetry-transport for SigNoz log correlation when OTEL is configured
*/
const buildTransport = (): LoggerOptions["transport"] => {
const isEdgeRuntime = process.env.NEXT_RUNTIME === "edge";
const hasOtelEndpoint =
process.env.NEXT_RUNTIME === "nodejs" && Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
@@ -77,6 +79,11 @@ const buildTransport = (): LoggerOptions["transport"] => {
};
if (!IS_PRODUCTION) {
// Edge Runtime does not support worker_threads — skip pino-pretty to avoid crashes
if (isEdgeRuntime) {
return undefined;
}
// Development: pretty print + optional OTEL
if (hasOtelEndpoint) {
return { targets: [prettyTarget, otelTarget] };

View File

@@ -1,4 +1,4 @@
import DOMPurify from "isomorphic-dompurify";
import { sanitize } from "isomorphic-dompurify";
import * as React from "react";
import { cn, stripInlineStyles } from "@/lib/utils";
@@ -39,7 +39,7 @@ function Label({
const isHtml = childrenString ? isValidHTML(strippedContent) : false;
const safeHtml =
isHtml && strippedContent
? DOMPurify.sanitize(strippedContent, {
? sanitize(strippedContent, {
ADD_ATTR: ["target"],
FORBID_ATTR: ["style"],
})

View File

@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from "clsx";
import DOMPurify from "isomorphic-dompurify";
import { sanitize } from "isomorphic-dompurify";
import { extendTailwindMerge } from "tailwind-merge";
const twMerge = extendTailwindMerge({
@@ -27,14 +27,16 @@ export function cn(...inputs: ClassValue[]): string {
export const stripInlineStyles = (html: string): string => {
if (!html) return html;
// Use DOMPurify to safely remove style attributes
// This is more secure than regex-based approaches and handles edge cases properly
return DOMPurify.sanitize(html, {
// Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
// DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
// `style-src` violations at parse time — before FORBID_ATTR can strip them.
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
// fixed quote delimiters, so no backtracking can occur.
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
return sanitize(preStripped, {
FORBID_ATTR: ["style"],
// Preserve the target attribute (e.g. target="_blank" on links) which is not
// in DOMPurify's default allow-list but is explicitly required downstream.
ADD_ATTR: ["target"],
// Keep other attributes and tags as-is, only remove style attributes
KEEP_CONTENT: true,
});
};

View File

@@ -4,6 +4,7 @@
"baseUrl": ".",
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2020", "ES2021.String"],
"noEmit": true,
"paths": {
"@/*": ["./src/*"]

View File

@@ -10,14 +10,16 @@ import DOMPurify from "isomorphic-dompurify";
export const stripInlineStyles = (html: string): string => {
if (!html) return html;
// Use DOMPurify to safely remove style attributes
// This is more secure than regex-based approaches and handles edge cases properly
return DOMPurify.sanitize(html, {
// Pre-strip style attributes from the raw string BEFORE DOMPurify parses it.
// DOMPurify internally uses innerHTML to parse HTML, which triggers CSP
// `style-src` violations at parse time — before FORBID_ATTR can strip them.
// The regex is O(n) safe: [^"]* and [^']* are negated classes bounded by
// fixed quote delimiters, so no backtracking can occur.
const preStripped = html.replaceAll(/ style="[^"]*"| style='[^']*'/gi, "");
return DOMPurify.sanitize(preStripped, {
FORBID_ATTR: ["style"],
// Preserve the target attribute (e.g. target="_blank" on links) which is not
// in DOMPurify's default allow-list but is explicitly required downstream.
ADD_ATTR: ["target"],
// Keep other attributes and tags as-is, only remove style attributes
KEEP_CONTENT: true,
});
};

View File

@@ -26,16 +26,56 @@ export const ZActionClassPageUrlRule = z.enum(ACTION_CLASS_PAGE_URL_RULES);
export type TActionClassPageUrlRule = z.infer<typeof ZActionClassPageUrlRule>;
const URL_LIKE_FILTER_RULES = new Set<TActionClassPageUrlRule>(["exactMatch", "startsWith", "notMatch"]);
const DOMAIN_HOSTNAME_REGEX = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
const isValidAbsoluteUrlFilterValue = (value: string): boolean => {
try {
const parsedUrl = new URL(value);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return false;
}
const isIPv6 = parsedUrl.hostname.startsWith("[") && parsedUrl.hostname.endsWith("]");
const isIPv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(parsedUrl.hostname);
return (
DOMAIN_HOSTNAME_REGEX.test(parsedUrl.hostname) || parsedUrl.hostname === "localhost" || isIPv6 || isIPv4
);
} catch {
return false;
}
};
export const isValidActionClassUrlFilterValue = (value: string, rule: TActionClassPageUrlRule): boolean => {
if (!URL_LIKE_FILTER_RULES.has(rule)) {
return true;
}
return value.startsWith("/") || isValidAbsoluteUrlFilterValue(value);
};
const ZActionClassUrlFilter = z
.object({
value: z.string().trim().min(1, {
error: "Value must contain at least 1 character",
}),
rule: ZActionClassPageUrlRule,
})
.superRefine((data, ctx) => {
if (!isValidActionClassUrlFilterValue(data.value, data.rule)) {
ctx.addIssue({
code: "custom",
path: ["value"],
message: "Please enter a valid URL (e.g., https://example.com)",
});
}
});
const ZActionClassNoCodeConfigBase = z.object({
type: z.enum(["click", "pageView", "exitIntent", "fiftyPercentScroll", "pageDwell"]),
urlFilters: z.array(
z.object({
value: z.string().trim().min(1, {
error: "Value must contain atleast 1 character",
}),
rule: ZActionClassPageUrlRule,
})
),
urlFilters: z.array(ZActionClassUrlFilter),
urlFiltersConnector: z.enum(["or", "and"]).optional(),
});

View File

@@ -333,6 +333,10 @@ export const ZSegmentFilters: z.ZodType<TBaseFilters> = z
error: "Invalid filters applied",
});
const ZRequiredSegmentFilters = ZSegmentFilters.refine((filters) => filters.length > 0, {
error: "At least one filter is required",
});
export const ZSegment = z.object({
id: z.string(),
title: z.string(),
@@ -350,7 +354,7 @@ export const ZSegmentCreateInput = z.object({
title: z.string(),
description: z.string().optional(),
isPrivate: z.boolean().prefault(true),
filters: ZSegmentFilters,
filters: ZRequiredSegmentFilters,
surveyId: z.string(),
});
@@ -367,7 +371,7 @@ export const ZSegmentUpdateInput = z
title: z.string(),
description: z.string().nullable(),
isPrivate: z.boolean().prefault(true),
filters: ZSegmentFilters,
filters: ZRequiredSegmentFilters,
surveys: z.array(z.string()),
})
.partial();