mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-20 11:22:55 -05:00
resolve conflict
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "フォーム表示設定",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Настройки отображения опроса",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "调查显示设置",
|
||||
|
||||
@@ -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": "問卷顯示設定",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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)) };
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -50,7 +50,11 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
|
||||
formbricks.track("{watch("key")}")
|
||||
</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>
|
||||
{"."}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] };
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"baseUrl": ".",
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020", "ES2021.String"],
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user