Compare commits

..

8 Commits

360 changed files with 3856 additions and 11656 deletions
+1 -12
View File
@@ -94,12 +94,6 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too. # Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1 PASSWORD_RESET_DISABLED=1
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
# DEBUG_SHOW_RESET_LINK=1
# Email login. Disable the ability for users to login with email. # Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1 # EMAIL_AUTH_DISABLED=1
@@ -191,11 +185,6 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app # Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1 # RATE_LIMITING_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics) # OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
@@ -242,4 +231,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation # Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here LINGODOTDEV_API_KEY=your_api_key_here
+1 -1
View File
@@ -45,7 +45,7 @@ yarn-error.log*
.direnv .direnv
# Playwright # Playwright
**/test-results/ /test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
-8
View File
@@ -52,14 +52,6 @@ We are using SonarQube to identify code smells and security hotspots.
- Translations are in `apps/web/locales/`. Default is `en-US.json`. - Translations are in `apps/web/locales/`. Default is `en-US.json`.
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys. - Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
## Date and Time Rendering
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
## Database & Prisma Performance ## Database & Prisma Performance
- Multi-tenancy: All data must be scoped by Organization or Environment. - Multi-tenancy: All data must be scoped by Organization or Environment.
-30
View File
@@ -41,14 +41,6 @@ The Open Source Qualtrics Alternative
<a href="https://trendshift.io/repositories/2570" target="_blank"><img src="https://trendshift.io/api/badge/repositories/2570" alt="Trendshift Badge for formbricks/formbricks" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/2570" target="_blank"><img src="https://trendshift.io/api/badge/repositories/2570" alt="Trendshift Badge for formbricks/formbricks" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p> </p>
## multilanguage
- [English](#readme-en)
- [Deutsch](#readme-de)
- [Español](#readme-es)
<a id="readme-en"></a>
## ✨ About Formbricks ## ✨ About Formbricks
<img width="1527" alt="formbricks-sneak" src="https://github-production-user-asset-6210df.s3.amazonaws.com/675065/249441967-ccb89ea3-82b4-4bf2-8d2c-528721ec313b.png"> <img width="1527" alt="formbricks-sneak" src="https://github-production-user-asset-6210df.s3.amazonaws.com/675065/249441967-ccb89ea3-82b4-4bf2-8d2c-528721ec313b.png">
@@ -255,26 +247,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come. The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
<a id="readme-de"></a>
## Deutsch
Formbricks ist eine freie, quelloffene und datenschutzorientierte Plattform für Surveys und Experience Management. Mit In-App-, Website-, Link- und E-Mail-Umfragen sammelt ihr Feedback entlang der gesamten User Journey.
- Website & Cloud: [formbricks.com](https://formbricks.com/) und [Cloud starten](https://app.formbricks.com/auth/signup)
- Self-Hosting: [Deployment-Dokumentation](https://formbricks.com/docs/self-hosting/deployment)
- Beitrag & Community: [Beitragen](https://formbricks.com/docs/developer-docs/contributing/get-started), [GitHub Discussions](https://github.com/formbricks/formbricks/discussions) und [Issues](https://github.com/formbricks/formbricks/issues)
- Sicherheit & Lizenz: [`SECURITY.md`](./SECURITY.md) und [AGPLv3](https://github.com/formbricks/formbricks/blob/main/LICENSE)
<a id="readme-es"></a>
## Español
Formbricks es una plataforma libre, de código abierto y centrada en la privacidad para encuestas y experience management. Permite recoger feedback durante todo el recorrido del usuario con encuestas dentro de la app, en sitios web, por enlace y por correo electrónico.
- Sitio web y Cloud: [formbricks.com](https://formbricks.com/) y [empezar en Cloud](https://app.formbricks.com/auth/signup)
- Self-Hosting: [documentación de despliegue](https://formbricks.com/docs/self-hosting/deployment)
- Contribución y comunidad: [guía para contribuir](https://formbricks.com/docs/developer-docs/contributing/get-started), [GitHub Discussions](https://github.com/formbricks/formbricks/discussions) e [Issues](https://github.com/formbricks/formbricks/issues)
- Seguridad y licencia: [`SECURITY.md`](./SECURITY.md) y [AGPLv3](https://github.com/formbricks/formbricks/blob/main/LICENSE)
<p align="right"><a href="#top">🔼 Back to top</a></p> <p align="right"><a href="#top">🔼 Back to top</a></p>
@@ -1,6 +1,5 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks"; import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
@@ -21,12 +20,12 @@ const Page = async (props: ConnectPageProps) => {
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
if (!environment) { if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId); throw new Error(t("common.environment_not_found"));
} }
const project = await getProjectByEnvironmentId(environment.id); const project = await getProjectByEnvironmentId(environment.id);
if (!project) { if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error(t("common.workspace_not_found"));
} }
const channel = project.config.channel || null; const channel = project.config.channel || null;
@@ -1,7 +1,6 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import Link from "next/link"; import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList"; import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service"; import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
@@ -24,22 +23,22 @@ const Page = async (props: XMTemplatePageProps) => {
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
const t = await getTranslate(); const t = await getTranslate();
if (!session) { if (!session) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.session_not_found"));
} }
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
if (!environment) { if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId); throw new Error(t("common.environment_not_found"));
} }
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id); const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id); const project = await getProjectByEnvironmentId(environment.id);
if (!project) { if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error(t("common.workspace_not_found"));
} }
const projects = await getUserProjects(session.user.id, organizationId); const projects = await getUserProjects(session.user.id, organizationId);
@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; import { AuthorizationError } from "@formbricks/types/errors";
import { canUserAccessOrganization } from "@/lib/organization/auth"; import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
@@ -25,7 +25,7 @@ const ProjectOnboardingLayout = async (props: {
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId); const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
@@ -36,7 +36,7 @@ const ProjectOnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId); const organization = await getOrganization(params.organizationId);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId); throw new Error(t("common.organization_not_found"));
} }
return ( return (
@@ -1,6 +1,5 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
@@ -29,7 +28,7 @@ const OnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId); const organization = await getOrganization(params.organizationId);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId); throw new Error(t("common.organization_not_found"));
} }
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([ const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
@@ -1,7 +1,6 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project"; import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
@@ -46,7 +45,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const isAccessControlAllowed = await getAccessControlPermission(organization.id); const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) { if (!organizationTeams) {
throw new ResourceNotFoundError(t("common.team"), null); throw new Error(t("common.organization_teams_not_found"));
} }
const publicDomain = getPublicDomain(); const publicDomain = getPublicDomain();
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
@@ -18,13 +17,13 @@ const SurveyEditorEnvironmentLayout = async (props: {
} }
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
const environment = await getEnvironment(params.environmentId); const environment = await getEnvironment(params.environmentId);
if (!environment) { if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId); throw new Error(t("common.environment_not_found"));
} }
return ( return (
@@ -2,11 +2,7 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
AuthorizationError,
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project"; import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
@@ -50,7 +46,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
const organization = await getOrganization(organizationId); const organization = await getOrganization(organizationId);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId); throw new Error("Organization not found");
} }
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id); const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
@@ -1,4 +1,3 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation"; import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar"; import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
@@ -43,7 +42,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
// Validate that project permission exists for members // Validate that project permission exists for members
if (isMember && !projectPermission) { if (isMember && !projectPermission) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error(t("common.workspace_permission_not_found"));
} }
return ( return (
@@ -11,6 +11,7 @@ import {
RocketIcon, RocketIcon,
UserCircleIcon, UserCircleIcon,
UserIcon, UserIcon,
WorkflowIcon,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -115,6 +116,13 @@ export const MainNavigation = ({
pathname?.includes("/segments") || pathname?.includes("/segments") ||
pathname?.includes("/attributes"), pathname?.includes("/attributes"),
}, },
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
},
{ {
name: t("common.configuration"), name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`, href: `/environments/${environment.id}/workspace/general`,
@@ -122,7 +130,7 @@ export const MainNavigation = ({
isActive: pathname?.includes("/workspace"), isActive: pathname?.includes("/workspace"),
}, },
], ],
[t, environment.id, pathname] [t, environment.id, pathname, isFormbricksCloud]
); );
const dropdownNavigation = [ const dropdownNavigation = [
@@ -1,5 +1,4 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
@@ -21,15 +20,15 @@ const AccountSettingsLayout = async (props: {
]); ]);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null); throw new Error(t("common.organization_not_found"));
} }
if (!project) { if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error(t("common.workspace_not_found"));
} }
if (!session) { if (!session) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.session_not_found"));
} }
return <>{children}</>; return <>{children}</>;
@@ -1,6 +1,5 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user"; import { TUserNotificationSettings } from "@formbricks/types/user";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
@@ -147,18 +146,18 @@ const Page = async (props: {
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session) { if (!session) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.session_not_found"));
} }
const autoDisableNotificationType = searchParams["type"]; const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"]; const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]); const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
if (!memberships) { if (!memberships) {
throw new ResourceNotFoundError(t("common.membership"), null); throw new Error(t("common.membership_not_found"));
} }
if (user?.notificationSettings) { if (user?.notificationSettings) {
@@ -10,16 +10,15 @@ import {
getIsEmailUnique, getIsEmailUnique,
verifyUserPassword, verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user"; } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { getUser, updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationNewEmail } from "@/modules/email"; import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput { function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
return { return {
@@ -86,15 +85,11 @@ export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPerso
export const resetPasswordAction = authenticatedActionClient.action( export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging("passwordReset", "user", async ({ ctx }) => { withAuditLogging("passwordReset", "user", async ({ ctx }) => {
if (PASSWORD_RESET_DISABLED) {
throw new OperationNotAllowedError("Password reset is disabled");
}
if (ctx.user.identityProvider !== "email") { if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user."); throw new OperationNotAllowedError("Password reset is not allowed for this user.");
} }
await requestPasswordReset(ctx.user, "profile"); await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id; ctx.auditLoggingCtx.userId = ctx.user.id;
@@ -1,4 +1,3 @@
import { AuthenticationError } from "@formbricks/types/errors";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants"; import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
@@ -29,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const user = session?.user ? await getUser(session.user.id) : null; const user = session?.user ? await getUser(session.user.id) : null;
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email"; const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
@@ -1,5 +1,4 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AuthenticationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils"; import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
@@ -26,7 +25,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
); );
if (!session) { if (!session) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.session_not_found"));
} }
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id); const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
@@ -1,146 +0,0 @@
"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>
);
};
@@ -6,7 +6,6 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
import { recheckLicenseAction } from "@/modules/ee/license-check/actions"; import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license"; import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
import { Alert, AlertDescription } from "@/modules/ui/components/alert"; import { Alert, AlertDescription } from "@/modules/ui/components/alert";
@@ -16,7 +15,6 @@ import { SettingsCard } from "../../../components/SettingsCard";
interface EnterpriseLicenseStatusProps { interface EnterpriseLicenseStatusProps {
status: TLicenseStatus; status: TLicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date; gracePeriodEnd?: Date;
environmentId: string; environmentId: string;
} }
@@ -46,12 +44,10 @@ const getBadgeConfig = (
export const EnterpriseLicenseStatus = ({ export const EnterpriseLicenseStatus = ({
status, status,
lastChecked,
gracePeriodEnd, gracePeriodEnd,
environmentId, environmentId,
}: EnterpriseLicenseStatusProps) => { }: EnterpriseLicenseStatusProps) => {
const { t, i18n } = useTranslation(); const { t } = useTranslation();
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
const router = useRouter(); const router = useRouter();
const [isRechecking, setIsRechecking] = useState(false); const [isRechecking, setIsRechecking] = useState(false);
@@ -96,12 +92,7 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-3"> <Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
</span>
</div>
</div> </div>
<Button <Button
type="button" type="button"
@@ -127,7 +118,7 @@ export const EnterpriseLicenseStatus = ({
<Alert variant="warning" size="small"> <Alert variant="warning" size="small">
<AlertDescription className="overflow-visible whitespace-normal"> <AlertDescription className="overflow-visible whitespace-normal">
{t("environments.settings.enterprise.license_unreachable_grace_period", { {t("environments.settings.enterprise.license_unreachable_grace_period", {
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, { gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
@@ -10,7 +10,6 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => { const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
@@ -94,19 +93,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
/> />
</PageHeader> </PageHeader>
{hasLicense ? ( {hasLicense ? (
<> <EnterpriseLicenseStatus
<EnterpriseLicenseStatus status={licenseState.status}
status={licenseState.status} gracePeriodEnd={
lastChecked={licenseState.lastChecked} licenseState.status === "unreachable"
gracePeriodEnd={ ? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
licenseState.status === "unreachable" : undefined
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS) }
: undefined environmentId={params.environmentId}
} />
environmentId={params.environmentId}
/>
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</>
) : ( ) : (
<div> <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"> <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">
@@ -3,41 +3,13 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors"; import { OperationNotAllowedError } from "@formbricks/types/errors";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations"; import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service"; import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
async function updateOrganizationAction<T extends z.ZodRawShape>({
ctx,
organizationId,
schema,
data,
roles,
}: {
ctx: AuthenticatedActionClientCtx;
organizationId: string;
schema: z.ZodObject<T>;
data: z.infer<z.ZodObject<T>>;
roles: TOrganizationRole[];
}) {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [{ type: "organization", schema, data, roles }],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const oldObject = await getOrganization(organizationId);
const result = await updateOrganization(organizationId, data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
const ZUpdateOrganizationNameAction = z.object({ const ZUpdateOrganizationNameAction = z.object({
organizationId: ZId, organizationId: ZId,
data: ZOrganizationUpdateInput.pick({ name: true }), data: ZOrganizationUpdateInput.pick({ name: true }),
@@ -46,55 +18,26 @@ const ZUpdateOrganizationNameAction = z.object({
export const updateOrganizationNameAction = authenticatedActionClient export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction) .inputSchema(ZUpdateOrganizationNameAction)
.action( .action(
withAuditLogging( withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
"updated", await checkAuthorizationUpdated({
"organization", userId: ctx.user.id,
async ({ organizationId: parsedInput.organizationId,
ctx, access: [
parsedInput, {
}: { type: "organization",
ctx: AuthenticatedActionClientCtx; schema: ZOrganizationUpdateInput.pick({ name: true }),
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>; data: parsedInput.data,
}) => roles: ["owner"],
updateOrganizationAction({ },
ctx, ],
organizationId: parsedInput.organizationId, });
schema: ZOrganizationUpdateInput.pick({ name: true }), ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
data: parsedInput.data, const oldObject = await getOrganization(parsedInput.organizationId);
roles: ["owner"], const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
}) ctx.auditLoggingCtx.oldObject = oldObject;
) ctx.auditLoggingCtx.newObject = result;
); return result;
})
const ZUpdateOrganizationAISettingsAction = z.object({
organizationId: ZId,
data: ZOrganizationUpdateInput.pick({ isAISmartToolsEnabled: true, isAIDataAnalysisEnabled: true }),
});
export const updateOrganizationAISettingsAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationAISettingsAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
}) =>
updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
schema: ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
}),
data: parsedInput.data,
roles: ["owner", "manager"],
})
)
); );
const ZDeleteOrganizationAction = z.object({ const ZDeleteOrganizationAction = z.object({
@@ -1,84 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
}
export const AISettingsToggle = ({ organization, membershipRole }: Readonly<AISettingsToggleProps>) => {
const [loadingField, setLoadingField] = useState<string | null>(null);
const { t } = useTranslation();
const router = useRouter();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const canEdit = isOwner || isManager;
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
setLoadingField(field);
try {
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data: { [field]: checked },
});
if (response?.data) {
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(response);
toast.error(errorMessage);
}
} catch {
toast.error(t("common.something_went_wrong"));
} finally {
setLoadingField(null);
}
};
return (
<div>
<AdvancedOptionToggle
isChecked={organization.isAISmartToolsEnabled}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
htmlId="ai-smart-tools-toggle"
title={t("environments.settings.general.ai_smart_tools_enabled")}
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
disabled={loadingField !== null || !canEdit}
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={organization.isAIDataAnalysisEnabled}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("environments.settings.general.ai_data_analysis_enabled")}
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
disabled={loadingField !== null || !canEdit}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</div>
);
};
@@ -11,7 +11,6 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json"; import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard"; import { SettingsCard } from "../../components/SettingsCard";
import { AISettingsToggle } from "./components/AISettingsToggle";
import { DeleteOrganization } from "./components/DeleteOrganization"; import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm"; import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip"; import { SecurityListTip } from "./components/SecurityListTip";
@@ -61,11 +60,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
membershipRole={currentUserMembership?.role} membershipRole={currentUserMembership?.role}
/> />
</SettingsCard> </SettingsCard>
<SettingsCard
title={t("environments.settings.general.ai_enabled")}
description={t("environments.settings.general.ai_enabled_description")}>
<AISettingsToggle organization={organization} membershipRole={currentUserMembership?.role} />
</SettingsCard>
<EmailCustomizationSettings <EmailCustomizationSettings
organization={organization} organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission} hasWhiteLabelPermission={hasWhiteLabelPermission}
@@ -1,5 +1,4 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
@@ -18,15 +17,15 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
]); ]);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null); throw new Error(t("common.organization_not_found"));
} }
if (!project) { if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error(t("common.workspace_not_found"));
} }
if (!session) { if (!session) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.session_not_found"));
} }
return <>{children}</>; return <>{children}</>;
@@ -96,8 +96,8 @@ export const ResponseTable = ({
const showQuotasColumn = isQuotasAllowed && quotas.length > 0; const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns // Generate columns
const columns = useMemo( const columns = useMemo(
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn), () => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn] [survey, isExpanded, isReadOnly, t, showQuotasColumn]
); );
// Save settings to localStorage when they change // Save settings to localStorage when they change
@@ -300,6 +300,7 @@ export const ResponseTable = ({
<DataTableSettingsModal <DataTableSettingsModal
open={isTableSettingsModalOpen} open={isTableSettingsModalOpen}
setOpen={setIsTableSettingsModalOpen} setOpen={setIsTableSettingsModalOpen}
survey={survey}
table={table} table={table}
columnOrder={columnOrder} columnOrder={columnOrder}
handleDragEnd={handleDragEnd} handleDragEnd={handleDragEnd}
@@ -8,11 +8,10 @@ import { TResponseTableData } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation"; import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils"; import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateTimeForDisplay } from "@/lib/utils/datetime"; import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall"; import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse"; import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -35,7 +34,6 @@ const getElementColumnsData = (
element: TSurveyElement, element: TSurveyElement,
survey: TSurvey, survey: TSurvey,
isExpanded: boolean, isExpanded: boolean,
locale: TUserLocale,
t: TFunction t: TFunction
): ColumnDef<TResponseTableData>[] => { ): ColumnDef<TResponseTableData>[] => {
const ELEMENTS_ICON_MAP = getElementIconMap(t); const ELEMENTS_ICON_MAP = getElementIconMap(t);
@@ -169,7 +167,6 @@ const getElementColumnsData = (
survey={survey} survey={survey}
responseData={responseValue} responseData={responseValue}
language={language} language={language}
locale={locale}
isExpanded={isExpanded} isExpanded={isExpanded}
showId={false} showId={false}
/> />
@@ -221,7 +218,6 @@ const getElementColumnsData = (
survey={survey} survey={survey}
responseData={responseValue} responseData={responseValue}
language={language} language={language}
locale={locale}
isExpanded={isExpanded} isExpanded={isExpanded}
showId={false} showId={false}
/> />
@@ -263,14 +259,11 @@ export const generateResponseTableColumns = (
survey: TSurvey, survey: TSurvey,
isExpanded: boolean, isExpanded: boolean,
isReadOnly: boolean, isReadOnly: boolean,
locale: TUserLocale,
t: TFunction, t: TFunction,
showQuotasColumn: boolean showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => { ): ColumnDef<TResponseTableData>[] => {
const elements = getElementsFromBlocks(survey.blocks); const elements = getElementsFromBlocks(survey.blocks);
const elementColumns = elements.flatMap((element) => const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
getElementColumnsData(element, survey, isExpanded, locale, t)
);
const dateColumn: ColumnDef<TResponseTableData> = { const dateColumn: ColumnDef<TResponseTableData> = {
accessorKey: "createdAt", accessorKey: "createdAt",
@@ -278,7 +271,7 @@ export const generateResponseTableColumns = (
size: 200, size: 200,
cell: ({ row }) => { cell: ({ row }) => {
const date = new Date(row.original.createdAt); const date = new Date(row.original.createdAt);
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>; return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
}, },
}; };
@@ -1,4 +1,3 @@
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage"; import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -8,6 +7,7 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments"; import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -23,24 +23,25 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId); const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([ const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
getSurvey(params.surveyId), getSurvey(params.surveyId),
getUser(session.user.id), getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId), getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(organization.id), getIsContactsEnabled(organization.id),
getResponseCountBySurveyId(params.surveyId), getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]); ]);
if (!survey) { if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), params.surveyId); throw new Error(t("common.survey_not_found"));
} }
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
if (!organization) { if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), null); throw new Error(t("common.organization_not_found"));
} }
const segments = isContactsEnabled ? await getSegments(params.environmentId) : []; const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
@@ -49,7 +50,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
const organizationBilling = await getOrganizationBilling(organization.id); const organizationBilling = await getOrganizationBilling(organization.id);
if (!organizationBilling) { if (!organizationBilling) {
throw new ResourceNotFoundError(t("common.organization"), organization.id); throw new Error(t("common.organization_not_found"));
} }
const isQuotasAllowed = await getIsQuotasEnabled(organization.id); const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
@@ -85,7 +86,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
environmentTags={tags} environmentTags={tags}
user={user} user={user}
responsesPerPage={RESPONSES_PER_PAGE} responsesPerPage={RESPONSES_PER_PAGE}
locale={user.locale} locale={locale}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed} isQuotasAllowed={isQuotasAllowed}
quotas={quotas} quotas={quotas}
@@ -64,17 +64,15 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({ const ZResetSurveyAction = z.object({
surveyId: ZId, surveyId: ZId,
organizationId: ZId,
projectId: ZId, projectId: ZId,
}); });
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action( export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => { withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId, organizationId: parsedInput.organizationId,
access: [ access: [
{ {
type: "organization", type: "organization",
@@ -83,12 +81,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
{ {
type: "projectTeam", type: "projectTeam",
minPermission: "readWrite", minPermission: "readWrite",
projectId, projectId: parsedInput.projectId,
}, },
], ],
}); });
ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId; ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null; ctx.auditLoggingCtx.oldObject = null;
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time"; import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact"; import { getContactIdentifier } from "@/lib/utils/contact";
import { formatStoredDateForDisplay } from "@/lib/utils/date-display"; import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { PersonAvatar } from "@/modules/ui/components/avatars"; import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state"; import { EmptyState } from "@/modules/ui/components/empty-state";
@@ -32,14 +32,13 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
}; };
const renderResponseValue = (value: string) => { const renderResponseValue = (value: string) => {
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale); const parsedDate = new Date(value);
return ( const formattedDate = isNaN(parsedDate.getTime())
formattedDate ?? ? `${t("common.invalid_date")}(${value})`
t("common.invalid_date_with_value", { : formatDateWithOrdinal(parsedDate);
value,
}) return formattedDate;
);
}; };
return ( return (
@@ -60,7 +59,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
elementSummary.samples.slice(0, visibleResponses).map((response) => ( elementSummary.samples.slice(0, visibleResponses).map((response) => (
<div <div
key={response.id} key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent"> className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6"> <div className="pl-4 md:pl-6">
{response.contact ? ( {response.contact ? (
<Link <Link
@@ -85,7 +84,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold"> <div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)} {renderResponseValue(response.value)}
</div> </div>
<div className="px-4 md:px-6"> <div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} {timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div> </div>
</div> </div>
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false); const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false); const [isResetting, setIsResetting] = useState(false);
const { project } = useEnvironment(); const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly); const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted; const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,6 +128,7 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true); setIsResetting(true);
const result = await resetSurveyAction({ const result = await resetSurveyAction({
surveyId: survey.id, surveyId: survey.id,
organizationId: organizationId,
projectId: project.id, projectId: project.id,
}); });
if (result?.data) { if (result?.data) {
@@ -1,4 +1,3 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
@@ -10,11 +9,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
const t = await getTranslate(); const t = await getTranslate();
const survey = await getSurvey(surveyId); const survey = await getSurvey(surveyId);
if (!survey) { if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), surveyId); throw new Error("Survey not found");
} }
const project = await getProjectByEnvironmentId(survey.environmentId); const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) { if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error("Workspace not found");
} }
const styling = getStyling(project, survey); const styling = getStyling(project, survey);
@@ -11,7 +11,8 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils"; import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { import {
getElementSummary, getElementSummary,
getResponsesForSummary, getResponsesForSummary,
@@ -43,7 +44,7 @@ vi.mock("@/lib/survey/service", () => ({
})); }));
vi.mock("@/lib/surveyLogic/utils", () => ({ vi.mock("@/lib/surveyLogic/utils", () => ({
evaluateLogic: vi.fn(), evaluateLogic: vi.fn(),
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })), performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
})); }));
vi.mock("@/lib/utils/validate", () => ({ vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(), validateInputs: vi.fn(),
@@ -228,6 +229,12 @@ describe("getSurveySummaryDropOff", () => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) => vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0 num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
); );
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
vi.mocked(performActions).mockReturnValue({
jumpTarget: undefined,
requiredElementIds: [],
calculations: {},
});
}); });
test("calculates dropOff correctly with welcome card disabled", () => { test("calculates dropOff correctly with welcome card disabled", () => {
@@ -239,7 +246,7 @@ describe("getSurveySummaryDropOff", () => {
contact: null, contact: null,
contactAttributes: {}, contactAttributes: {},
language: "en", language: "en",
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it ttc: { q1: 10 },
finished: false, finished: false,
}, // Dropped at q2 }, // Dropped at q2
{ {
@@ -262,55 +269,22 @@ describe("getSurveySummaryDropOff", () => {
); );
expect(dropOff.length).toBe(2); expect(dropOff.length).toBe(2);
// Q1: welcome card disabled so impressions = displayCount // Q1
expect(dropOff[0].elementId).toBe("q1"); expect(dropOff[0].elementId).toBe("q1");
expect(dropOff[0].impressions).toBe(displayCount); expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1 expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100 expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
expect(dropOff[0].ttc).toBe(10); expect(dropOff[0].ttc).toBe(10);
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2) // Q2
expect(dropOff[1].elementId).toBe("q2"); expect(dropOff[1].elementId).toBe("q2");
expect(dropOff[1].impressions).toBe(2); expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element) expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100 expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms) expect(dropOff[1].ttc).toBe(10);
}); });
test("drop-off attributed to last seen element when user doesn't reach next question", () => { test("handles logic jumps", () => {
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
const surveyWithWelcome: TSurvey = {
...surveyWithBlocks,
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
};
const responses = [
{
id: "r1",
data: { q1: "a" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: "en",
ttc: { q1: 10 }, // Only saw q1, never reached q2
finished: false,
},
] as any;
const displayCount = 1;
const dropOff = getSurveySummaryDropOff(
surveyWithWelcome,
getElementsFromBlocks(surveyWithWelcome.blocks),
responses,
displayCount
);
expect(dropOff[0].impressions).toBe(1); // Saw q1
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
expect(dropOff[1].impressions).toBe(0); // Never saw q2
expect(dropOff[1].dropOffCount).toBe(0);
});
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
const surveyWithLogic: TSurvey = { const surveyWithLogic: TSurvey = {
...mockBaseSurvey, ...mockBaseSurvey,
blocks: [ blocks: [
@@ -341,6 +315,36 @@ describe("getSurveySummaryDropOff", () => {
charLimit: { enabled: false }, charLimit: { enabled: false },
}, },
] as TSurveyElement[], ] as TSurveyElement[],
logic: [
{
id: "logic1",
conditions: {
id: "condition1",
connector: "and" as const,
conditions: [
{
id: "c1",
leftOperand: {
type: "element" as const,
value: "q2",
},
operator: "equals" as const,
rightOperand: {
type: "static" as const,
value: "b",
},
},
],
},
actions: [
{
id: "action1",
objective: "jumpToBlock" as const,
target: "q4",
},
],
},
],
}, },
{ {
id: "block3", id: "block3",
@@ -373,21 +377,28 @@ describe("getSurveySummaryDropOff", () => {
], ],
questions: [], questions: [],
}; };
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
const responses = [ const responses = [
{ {
id: "r1", id: "r1",
data: { q1: "a", q2: "b", q4: "d" }, data: { q1: "a", q2: "b" },
updatedAt: new Date(), updatedAt: new Date(),
contact: null, contact: null,
contactAttributes: {}, contactAttributes: {},
language: "en", language: "en",
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic ttc: { q1: 10, q2: 10 },
finished: false, finished: false,
}, }, // Jumps from q2 to q4, drops at q4
]; ];
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
// Simulate logic on q2 triggering
return data.q2 === "b";
});
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
}
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
});
const dropOff = getSurveySummaryDropOff( const dropOff = getSurveySummaryDropOff(
surveyWithLogic, surveyWithLogic,
@@ -396,11 +407,11 @@ describe("getSurveySummaryDropOff", () => {
1 1
); );
expect(dropOff[0].impressions).toBe(1); // q1: seen expect(dropOff[0].impressions).toBe(1); // q1
expect(dropOff[1].impressions).toBe(1); // q2: seen expect(dropOff[1].impressions).toBe(1); // q2
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data) expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished) expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
}); });
}); });
@@ -11,6 +11,7 @@ import {
TResponseData, TResponseData,
TResponseFilterCriteria, TResponseFilterCriteria,
TResponseTtc, TResponseTtc,
TResponseVariables,
ZResponseFilterCriteria, ZResponseFilterCriteria,
} from "@formbricks/types/responses"; } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
@@ -36,7 +37,8 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils"; import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils"; import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { convertFloatTo2Decimal } from "./utils"; import { convertFloatTo2Decimal } from "./utils";
@@ -91,13 +93,63 @@ export const getSurveySummaryMeta = (
}; };
}; };
// Determine whether a response interacted with a given element. const evaluateLogicAndGetNextElementId = (
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer. localSurvey: TSurvey,
// This is more reliable than replaying survey logic, which can misattribute impressions elements: TSurveyElement[],
// when branching logic skips elements or when partial response data is insufficient data: TResponseData,
// to evaluate conditions correctly. localVariables: TResponseVariables,
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => { currentElementIndex: number,
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined; currElementTemp: TSurveyElement,
selectedLanguage: string | null
): {
nextElementId: string | undefined;
updatedSurvey: TSurvey;
updatedVariables: TResponseVariables;
} => {
let updatedSurvey = { ...localSurvey };
let updatedVariables = { ...localVariables };
let firstJumpTarget: string | undefined;
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
if (currentBlock?.logic && currentBlock.logic.length > 0) {
for (const logic of currentBlock.logic) {
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
const { jumpTarget, requiredElementIds, calculations } = performActions(
updatedSurvey,
logic.actions,
data,
updatedVariables
);
if (requiredElementIds.length > 0) {
// Update blocks to mark elements as required
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
...block,
elements: block.elements.map((e) =>
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
),
}));
}
updatedVariables = { ...updatedVariables, ...calculations };
if (jumpTarget && !firstJumpTarget) {
firstJumpTarget = jumpTarget;
}
}
}
}
// If no jump target was set, check for a fallback logic
if (!firstJumpTarget && currentBlock?.logicFallback) {
firstJumpTarget = currentBlock.logicFallback;
}
// Return the first jump target if found, otherwise go to the next element
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
return { nextElementId, updatedSurvey, updatedVariables };
}; };
export const getSurveySummaryDropOff = ( export const getSurveySummaryDropOff = (
@@ -118,8 +170,16 @@ export const getSurveySummaryDropOff = (
let impressionsArr = new Array(elements.length).fill(0) as number[]; let impressionsArr = new Array(elements.length).fill(0) as number[];
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[]; let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
const surveyVariablesData = survey.variables?.reduce(
(acc, variable) => {
acc[variable.id] = variable.value;
return acc;
},
{} as Record<string, string | number>
);
responses.forEach((response) => { responses.forEach((response) => {
// Calculate total time-to-completion per element // Calculate total time-to-completion
Object.keys(totalTtc).forEach((elementId) => { Object.keys(totalTtc).forEach((elementId) => {
if (response.ttc && response.ttc[elementId]) { if (response.ttc && response.ttc[elementId]) {
totalTtc[elementId] += response.ttc[elementId]; totalTtc[elementId] += response.ttc[elementId];
@@ -127,21 +187,51 @@ export const getSurveySummaryDropOff = (
} }
}); });
// Count impressions based on actual interaction data (ttc + response data) let localSurvey = structuredClone(survey);
// instead of replaying survey logic which is unreliable with branching let localResponseData: TResponseData = { ...response.data };
let lastSeenIdx = -1; let localVariables: TResponseVariables = {
...surveyVariablesData,
};
for (let i = 0; i < elements.length; i++) { let currQuesIdx = 0;
const element = elements[i];
if (wasElementSeen(response, element.id)) { while (currQuesIdx < elements.length) {
impressionsArr[i]++; const currQues = elements[currQuesIdx];
lastSeenIdx = i; if (!currQues) break;
// element is not answered and required
if (response.data[currQues.id] === undefined && currQues.required) {
dropOffArr[currQuesIdx]++;
impressionsArr[currQuesIdx]++;
break;
} }
}
// Attribute drop-off to the last element the respondent interacted with impressionsArr[currQuesIdx]++;
if (!response.finished && lastSeenIdx >= 0) {
dropOffArr[lastSeenIdx]++; const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
localSurvey,
elements,
localResponseData,
localVariables,
currQuesIdx,
currQues,
response.language
);
localSurvey = updatedSurvey;
localVariables = updatedVariables;
if (nextElementId) {
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
if (!response.data[nextElementId] && !response.finished) {
dropOffArr[nextQuesIdx]++;
impressionsArr[nextQuesIdx]++;
break;
}
currQuesIdx = nextQuesIdx;
} else {
currQuesIdx++;
}
} }
}); });
@@ -150,8 +240,6 @@ export const getSurveySummaryDropOff = (
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0; totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
}); });
// When the welcome card is disabled, the first element's impressions should equal displayCount
// because every survey display is an impression of the first element
if (!survey.welcomeCard.enabled) { if (!survey.welcomeCard.enabled) {
dropOffArr[0] = displayCount - impressionsArr[0]; dropOffArr[0] = displayCount - impressionsArr[0];
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0; if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
@@ -163,7 +251,7 @@ export const getSurveySummaryDropOff = (
impressionsArr[0] = displayCount; impressionsArr[0] = displayCount;
} else { } else {
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0; dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
} }
for (let i = 1; i < elements.length; i++) { for (let i = 1; i < elements.length; i++) {
@@ -1,5 +1,4 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation"; import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage"; import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA"; import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
@@ -33,13 +32,13 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const survey = await getSurvey(params.surveyId); const survey = await getSurvey(params.surveyId);
if (!survey) { if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), params.surveyId); throw new Error(t("common.survey_not_found"));
} }
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id); const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
@@ -47,11 +46,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const segments = isContactsEnabled ? await getSegments(environment.id) : []; const segments = isContactsEnabled ? await getSegments(environment.id) : [];
if (!organizationId) { if (!organizationId) {
throw new ResourceNotFoundError(t("common.organization"), null); throw new Error(t("common.organization_not_found"));
} }
const organizationBilling = await getOrganizationBilling(organizationId); const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) { if (!organizationBilling) {
throw new ResourceNotFoundError(t("common.organization"), organizationId); throw new Error(t("common.organization_not_found"));
} }
const isQuotasAllowed = await getIsQuotasEnabled(organizationId); const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
@@ -2,16 +2,21 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses"; import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service"; import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas"; import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey"; import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZGetResponsesDownloadUrlAction = z.object({ const ZGetResponsesDownloadUrlAction = z.object({
@@ -92,3 +97,68 @@ export const getSurveyFilterDataAction = authenticatedActionClient
return { environmentTags: tags, attributes, meta, hiddenFields, quotas }; return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
}); });
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param {string} organizationId The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if the permission is granted.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
})
);
@@ -1,7 +1,6 @@
"use client"; "use client";
import clsx from "clsx"; import clsx from "clsx";
import { TFunction } from "i18next";
import { import {
AirplayIcon, AirplayIcon,
ArrowUpFromDotIcon, ArrowUpFromDotIcon,
@@ -55,25 +54,6 @@ export enum OptionsType {
QUOTAS = "Quotas", QUOTAS = "Quotas",
} }
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
switch (type) {
case OptionsType.ELEMENTS:
return t("common.elements");
case OptionsType.TAGS:
return t("common.tags");
case OptionsType.ATTRIBUTES:
return t("common.attributes");
case OptionsType.OTHERS:
return t("common.other_filters");
case OptionsType.META:
return t("common.meta");
case OptionsType.HIDDEN_FIELDS:
return t("common.hidden_fields");
case OptionsType.QUOTAS:
return t("common.quotas");
}
};
export type ElementOption = { export type ElementOption = {
label: string; label: string;
elementType?: TSurveyElementTypeEnum; elementType?: TSurveyElementTypeEnum;
@@ -238,12 +218,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
{options?.map((data) => ( {options?.map((data) => (
<Fragment key={data.header}> <Fragment key={data.header}>
{data?.option.length > 0 && ( {data?.option.length > 0 && (
<CommandGroup <CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
heading={
<p className="text-sm font-medium text-slate-600">
{getOptionsTypeTranslationKey(data.header, t)}
</p>
}>
{data?.option?.map((o) => ( {data?.option?.map((o) => (
<CommandItem <CommandItem
key={o.id} key={o.id}
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -15,6 +14,7 @@ import {
SelectValue, SelectValue,
} from "@/modules/ui/components/select"; } from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator"; import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { updateSurveyAction } from "../actions";
interface SurveyStatusDropdownProps { interface SurveyStatusDropdownProps {
environment: TEnvironment; environment: TEnvironment;
@@ -1,6 +1,4 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { SurveyContextWrapper } from "./context/survey-context"; import { SurveyContextWrapper } from "./context/survey-context";
interface SurveyLayoutProps { interface SurveyLayoutProps {
@@ -12,10 +10,9 @@ const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
const resolvedParams = await params; const resolvedParams = await params;
const survey = await getSurvey(resolvedParams.surveyId); const survey = await getSurvey(resolvedParams.surveyId);
const t = await getTranslate();
if (!survey) { if (!survey) {
throw new ResourceNotFoundError(t("common.survey"), resolvedParams.surveyId); throw new Error("Survey not found");
} }
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>; return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
@@ -0,0 +1,208 @@
"use client";
import { CheckCircle2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
const FORMBRICKS_HOST = "https://app.formbricks.com";
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
interface WorkflowsPageProps {
userEmail: string;
organizationName: string;
billingPlan: string;
}
type Step = "prompt" | "followup" | "thankyou";
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("prompt");
const [promptValue, setPromptValue] = useState("");
const [detailsValue, setDetailsValue] = useState("");
const [responseId, setResponseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleGenerateWorkflow = async () => {
if (promptValue.trim().length < 100 || isSubmitting) return;
setIsSubmitting(true);
try {
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
surveyId: SURVEY_ID,
finished: false,
data: {
workflow: promptValue.trim(),
useremail: userEmail,
orgname: organizationName,
billingplan: billingPlan,
},
}),
});
if (res.ok) {
const json = await res.json();
setResponseId(json.data?.id ?? null);
}
setStep("followup");
} catch {
setStep("followup");
} finally {
setIsSubmitting(false);
}
};
const handleSubmitFeedback = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
if (responseId) {
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {
details: detailsValue.trim(),
},
}),
});
} catch {
// silently fail
}
}
setIsSubmitting(false);
setStep("thankyou");
};
const handleSkipFeedback = async () => {
if (!responseId) {
setStep("thankyou");
return;
}
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {},
}),
});
} catch {
// silently fail
}
setStep("thankyou");
};
if (step === "prompt") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
</div>
<div className="relative">
<textarea
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
}
}}
/>
<div className="mt-3 flex items-center justify-between">
<span
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
{promptValue.trim().length} / 100
</span>
<Button
onClick={handleGenerateWorkflow}
disabled={promptValue.trim().length < 100 || isSubmitting}
loading={isSubmitting}
size="lg">
<Sparkles className="h-4 w-4" />
{t("workflows.generate_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
if (step === "followup") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="h-6 w-6 text-brand-dark" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
</h1>
<p className="mx-auto max-w-md text-base text-slate-500">
{t("workflows.coming_soon_description")}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<label className="text-md mb-2 block font-medium text-slate-700">
{t("workflows.follow_up_label")}
</label>
<textarea
value={detailsValue}
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
{t("common.skip")}
</Button>
<Button
onClick={handleSubmitFeedback}
disabled={!detailsValue.trim() || isSubmitting}
loading={isSubmitting}>
{t("workflows.submit_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
</div>
</div>
);
};
@@ -0,0 +1,42 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
export const metadata: Metadata = {
title: "Workflows",
};
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect("/auth/login");
}
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
/>
);
};
export default Page;
@@ -4,9 +4,9 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper"; import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service"; import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants"; import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service"; import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service"; import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -18,12 +18,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate(); const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID; const isEnabled = !!AIRTABLE_CLIENT_ID;
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId); const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations, locale] = await Promise.all([ const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrations(params.environmentId), getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]); ]);
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find( const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
@@ -34,6 +33,9 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
if (airtableIntegration?.config.key) { if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId); airtableArray = await getAirtableTables(params.environmentId);
} }
const locale = await findMatchingLocale();
if (isReadOnly) { if (isReadOnly) {
return redirect("./"); return redirect("./");
} }
@@ -50,7 +52,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
environmentId={environment.id} environmentId={environment.id}
surveys={surveys} surveys={surveys}
webAppUrl={WEBAPP_URL} webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE} locale={locale}
/> />
</div> </div>
</PageContentWrapper> </PageContentWrapper>
@@ -3,14 +3,13 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-s
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper"; import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { import {
DEFAULT_LOCALE,
GOOGLE_SHEETS_CLIENT_ID, GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET, GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL, GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL, WEBAPP_URL,
} from "@/lib/constants"; } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service"; import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service"; import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -22,17 +21,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate(); const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL); const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId); const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations, locale] = await Promise.all([ const [surveys, integrations] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrations(params.environmentId), getIntegrations(params.environmentId),
getUserLocale(session.user.id),
]); ]);
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find( const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets" (integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
); );
const locale = await findMatchingLocale();
if (isReadOnly) { if (isReadOnly) {
return redirect("./"); return redirect("./");
} }
@@ -48,7 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys} surveys={surveys}
googleSheetIntegration={googleSheetIntegration} googleSheetIntegration={googleSheetIntegration}
webAppUrl={WEBAPP_URL} webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE} locale={locale}
/> />
</div> </div>
</PageContentWrapper> </PageContentWrapper>
@@ -3,7 +3,6 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper"; import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
import { import {
DEFAULT_LOCALE,
NOTION_AUTH_URL, NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID, NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET, NOTION_OAUTH_CLIENT_SECRET,
@@ -12,7 +11,7 @@ import {
} from "@/lib/constants"; } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service"; import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/service"; import { getNotionDatabases } from "@/lib/notion/service";
import { getUserLocale } from "@/lib/user/service"; import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -29,18 +28,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
NOTION_REDIRECT_URI NOTION_REDIRECT_URI
); );
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId); const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, notionIntegration, locale] = await Promise.all([ const [surveys, notionIntegration] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"), getIntegrationByType(params.environmentId, "notion"),
getUserLocale(session.user.id),
]); ]);
let databasesArray: TIntegrationNotionDatabase[] = []; let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) { if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? []; databasesArray = (await getNotionDatabases(environment.id)) ?? [];
} }
const locale = await findMatchingLocale();
if (isReadOnly) { if (isReadOnly) {
return redirect("./"); return redirect("./");
@@ -57,7 +56,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
notionIntegration={notionIntegration as TIntegrationNotion} notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL} webAppUrl={WEBAPP_URL}
databasesArray={databasesArray} databasesArray={databasesArray}
locale={locale ?? DEFAULT_LOCALE} locale={locale}
/> />
</PageContentWrapper> </PageContentWrapper>
); );
@@ -2,9 +2,9 @@ import { redirect } from "next/navigation";
import { TIntegrationSlack } from "@formbricks/types/integration/slack"; import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys"; import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper"; import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants"; import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service"; import { getIntegrationByType } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service"; import { findMatchingLocale } from "@/lib/utils/locale";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { GoBackButton } from "@/modules/ui/components/go-back-button";
@@ -17,14 +17,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate(); const t = await getTranslate();
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId); const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, slackIntegration, locale] = await Promise.all([ const [surveys, slackIntegration] = await Promise.all([
getSurveys(params.environmentId), getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"), getIntegrationByType(params.environmentId, "slack"),
getUserLocale(session.user.id),
]); ]);
const locale = await findMatchingLocale();
if (isReadOnly) { if (isReadOnly) {
return redirect("./"); return redirect("./");
} }
@@ -40,7 +41,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys} surveys={surveys}
slackIntegration={slackIntegration as TIntegrationSlack} slackIntegration={slackIntegration as TIntegrationSlack}
webAppUrl={WEBAPP_URL} webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE} locale={locale}
/> />
</div> </div>
</PageContentWrapper> </PageContentWrapper>
+2 -4
View File
@@ -6,10 +6,8 @@ import {
CHATWOOT_WEBSITE_TOKEN, CHATWOOT_WEBSITE_TOKEN,
IS_CHATWOOT_CONFIGURED, IS_CHATWOOT_CONFIGURED,
POSTHOG_KEY, POSTHOG_KEY,
SESSION_MAX_AGE,
} from "@/lib/constants"; } from "@/lib/constants";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { NextAuthProvider } from "@/modules/auth/components/next-auth-provider";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout"; import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay"; import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
@@ -25,7 +23,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
} }
return ( return (
<NextAuthProvider sessionMaxAge={SESSION_MAX_AGE}> <>
<NoMobileOverlay /> <NoMobileOverlay />
{POSTHOG_KEY && user && ( {POSTHOG_KEY && user && (
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} /> <PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
@@ -41,7 +39,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
)} )}
<ToasterClient /> <ToasterClient />
{children} {children}
</NextAuthProvider> </>
); );
}; };
@@ -1,139 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { GET } from "./route";
const mocks = vi.hoisted(() => {
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
const nextAuth = vi.fn(() => nextAuthHandler);
return {
nextAuth,
nextAuthHandler,
baseSignIn: vi.fn(async () => true),
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
baseEventSignIn: vi.fn(),
queueAuditEventBackground: vi.fn(),
captureException: vi.fn(),
loggerError: vi.fn(),
};
});
vi.mock("next-auth", () => ({
default: mocks.nextAuth,
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: undefined,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: mocks.captureException,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.loggerError,
})),
},
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {
callbacks: {
signIn: mocks.baseSignIn,
session: mocks.baseSession,
},
events: {
signIn: mocks.baseEventSignIn,
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
const request = new Request("http://localhost/api/auth/signin", {
headers: { "x-request-id": requestId },
});
await GET(request, {} as any);
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
return mocks.nextAuth.mock.calls[0][0];
};
describe("auth route audit logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
const authOptions = await getWrappedAuthOptions();
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
const account = { provider: "keycloak" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_1",
targetId: "user_1",
organizationId: "unknown",
status: "success",
userType: "user",
newObject: expect.objectContaining({
email: "user@example.com",
authMethod: "sso",
provider: "keycloak",
sessionStrategy: "database",
isNewUser: false,
}),
})
);
});
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
const error = new Error("Access denied");
mocks.baseSignIn.mockRejectedValueOnce(error);
const authOptions = await getWrappedAuthOptions("req-failure");
const user = { id: "user_2", email: "user2@example.com" };
const account = { provider: "credentials" };
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_2",
targetId: "user_2",
organizationId: "unknown",
status: "failure",
userType: "user",
eventId: "req-failure",
newObject: expect.objectContaining({
email: "user2@example.com",
authMethod: "password",
provider: "credentials",
errorMessage: "Access denied",
}),
})
);
});
});
+64 -63
View File
@@ -6,26 +6,10 @@ import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants"; import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions"; import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler"; import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const fetchCache = "force-no-store"; export const fetchCache = "force-no-store";
const getAuthMethod = (account: Account | null) => {
if (account?.provider === "credentials") {
return "password";
}
if (account?.provider === "token") {
return "email_verification";
}
if (account?.provider) {
return "sso";
}
return "unknown";
};
const handler = async (req: Request, ctx: any) => { const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined; const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -33,6 +17,44 @@ const handler = async (req: Request, ctx: any) => {
...baseAuthOptions, ...baseAuthOptions,
callbacks: { callbacks: {
...baseAuthOptions.callbacks, ...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) { async session(params: any) {
let result: any = params.session; let result: any = params.session;
let error: any = undefined; let error: any = undefined;
@@ -68,7 +90,7 @@ const handler = async (req: Request, ctx: any) => {
}) { }) {
let result: boolean | string = true; let result: boolean | string = true;
let error: any = undefined; let error: any = undefined;
const authMethod = getAuthMethod(account); let authMethod = "unknown";
try { try {
if (baseAuthOptions.callbacks?.signIn) { if (baseAuthOptions.callbacks?.signIn) {
@@ -80,6 +102,15 @@ const handler = async (req: Request, ctx: any) => {
credentials, credentials,
}); });
} }
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) { } catch (err) {
error = err; error = err;
result = false; result = false;
@@ -91,58 +122,28 @@ const handler = async (req: Request, ctx: any) => {
} }
} }
if (result === false) { const status: TAuditStatus = result === false ? "failure" : "success";
queueAuditEventBackground({ const auditLog = {
action: "signedIn", action: "signedIn" as const,
targetType: "user", targetType: "user" as const,
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "failure",
userType: "user",
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error instanceof Error ? { errorMessage: error.message } : {}),
},
eventId,
});
}
if (error) throw error;
return result;
},
},
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA, userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA, targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA, organizationId: UNKNOWN_DATA,
status: "success", status,
userType: "user", userType: "user" as const,
newObject: { newObject: {
...user, ...user,
authMethod: getAuthMethod(account), authMethod,
provider: account?.provider, provider: account?.provider,
sessionStrategy: "database", ...(error ? { errorMessage: error.message } : {}),
isNewUser: isNewUser ?? false,
}, },
}); ...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (error) throw error;
return result;
}, },
}, },
}; };
@@ -76,8 +76,7 @@ const mockOrganization: TOrganization = {
}, },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAISmartToolsEnabled: false, isAIEnabled: false,
isAIDataAnalysisEnabled: false,
}; };
const mockSurveys: TSurvey[] = [ const mockSurveys: TSurvey[] = [
@@ -49,8 +49,7 @@ const mockOrganization: TOrganization = {
}, },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAISmartToolsEnabled: false, isAIEnabled: false,
isAIDataAnalysisEnabled: false,
}; };
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = { const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {
-324
View File
@@ -1,324 +0,0 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "./api-wrapper";
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
mockGetServerSession: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession,
}));
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: mockAuthenticateRequest,
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
describe("withV3ApiWrapper", () => {
beforeEach(() => {
vi.resetAllMocks();
mockGetServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
});
afterEach(() => {
vi.clearAllMocks();
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ authentication, requestId, instance }) => {
expect(authentication).toMatchObject({ user: { id: "user_1" } });
expect(requestId).toBe("req-1");
expect(instance).toBe("/api/v3/surveys");
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?limit=10", {
headers: { "x-request-id": "req-1" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(response.headers.get("X-Request-Id")).toBe("req-1");
expect(handler).toHaveBeenCalledOnce();
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
expect.objectContaining({ namespace: "api:v3" }),
"user_1"
);
expect(mockAuthenticateRequest).not.toHaveBeenCalled();
});
test("falls back to api key auth in both mode", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: false } },
environmentPermissions: [],
});
const handler = vi.fn(async ({ authentication }) => {
expect(authentication).toMatchObject({ apiKeyId: "key_1" });
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
headers: { "x-api-key": "fbk_test" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
expect.objectContaining({ namespace: "api:v3" }),
"key_1"
);
expect(mockGetServerSession).not.toHaveBeenCalled();
});
test("returns 401 problem response when authentication is required but missing", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
expect(response.headers.get("Content-Type")).toBe("application/problem+json");
});
test("returns 400 problem response for invalid query input", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
schemas: {
query: z.object({
limit: z.coerce.number().int().positive(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?limit=oops", {
headers: { "x-request-id": "req-invalid" },
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual(expect.arrayContaining([expect.objectContaining({ name: "limit" })]));
expect(body.requestId).toBe("req-invalid");
});
test("parses body, repeated query params, and async route params", async () => {
const handler = vi.fn(async ({ parsedInput }) => {
expect(parsedInput).toEqual({
body: { name: "Survey API" },
query: { tag: ["a", "b"] },
params: { workspaceId: "ws_123" },
});
return Response.json(
{ ok: true },
{
headers: {
"X-Request-Id": "handler-request-id",
},
}
);
});
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
query: z.object({
tag: z.array(z.string()),
}),
params: z.object({
workspaceId: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?tag=a&tag=b", {
method: "POST",
body: JSON.stringify({ name: "Survey API" }),
headers: {
"Content-Type": "application/json",
},
}),
{
params: Promise.resolve({
workspaceId: "ws_123",
}),
} as never
);
expect(response.status).toBe(200);
expect(response.headers.get("X-Request-Id")).toBe("handler-request-id");
expect(handler).toHaveBeenCalledOnce();
});
test("returns 400 problem response for malformed JSON input", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: "{",
headers: {
"Content-Type": "application/json",
},
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual([
{
name: "body",
reason: "Malformed JSON input, please check your request body",
},
]);
});
test("returns 400 problem response for invalid route params", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
params: z.object({
workspaceId: z.string().min(3),
}),
},
handler,
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {
params: Promise.resolve({
workspaceId: "x",
}),
} as never);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "workspaceId" })])
);
});
test("returns 429 problem response when rate limited", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
vi.mocked(applyRateLimit).mockRejectedValueOnce(new TooManyRequestsError("Too many requests", 60));
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => Response.json({ ok: true }),
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(429);
expect(response.headers.get("Retry-After")).toBe("60");
const body = await response.json();
expect(body.code).toBe("too_many_requests");
});
test("returns 500 problem response when the handler throws unexpectedly", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => {
throw new Error("boom");
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
headers: { "x-request-id": "req-boom" },
}),
{} as never
);
expect(response.status).toBe(500);
const body = await response.json();
expect(body.code).toBe("internal_server_error");
expect(body.requestId).toBe("req-boom");
});
});
-349
View File
@@ -1,349 +0,0 @@
import { getServerSession } from "next-auth";
import { type NextRequest } from "next/server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import {
type InvalidParam,
problemBadRequest,
problemInternalError,
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
export type TV3AuthMode = "none" | "session" | "apiKey" | "both";
export type TV3Schemas = {
body?: TV3Schema;
query?: TV3Schema;
params?: TV3Schema;
};
export type TV3ParsedInput<S extends TV3Schemas | undefined> = S extends object
? {
[K in keyof S as NonNullable<S[K]> extends TV3Schema ? K : never]: z.infer<NonNullable<S[K]>>;
}
: Record<string, never>;
export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unknown> = {
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
parsedInput: TParsedInput;
requestId: string;
instance: string;
};
export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = unknown> = {
auth?: TV3AuthMode;
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
if (authMode === "session") {
return "Session required";
}
if (authMode === "apiKey") {
return "API key required";
}
return "Not authenticated";
}
function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
return error.issues.map((issue) => ({
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
}));
}
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
const query: Record<string, string | string[]> = {};
for (const key of new Set(searchParams.keys())) {
const values = searchParams.getAll(key);
query[key] = values.length > 1 ? values : (values[0] ?? "");
}
return query;
}
function getRateLimitIdentifier(authentication: TV3Authentication): string | null {
if (!authentication) {
return null;
}
if ("user" in authentication && authentication.user?.id) {
return authentication.user.id;
}
if ("apiKeyId" in authentication) {
return authentication.apiKeyId;
}
return null;
}
function isPromiseLike<T>(value: unknown): value is Promise<T> {
return typeof value === "object" && value !== null && "then" in value;
}
async function getRouteParams<TProps>(props: TProps): Promise<Record<string, unknown>> {
if (!props || typeof props !== "object" || !("params" in props)) {
return {};
}
const params = (props as { params?: unknown }).params;
if (!params) {
return {};
}
const resolvedParams = isPromiseLike<Record<string, unknown>>(params) ? await params : params;
return typeof resolvedParams === "object" && resolvedParams !== null
? (resolvedParams as Record<string, unknown>)
: {};
}
async function authenticateV3Request(req: NextRequest, authMode: TV3AuthMode): Promise<TV3Authentication> {
if (authMode === "none") {
return null;
}
if (authMode === "both" && req.headers.has("x-api-key")) {
const apiKeyAuth = await authenticateRequest(req);
if (apiKeyAuth) {
return apiKeyAuth;
}
}
if (authMode === "session" || authMode === "both") {
const session = await getServerSession(authOptions);
if (session?.user?.id) {
return session;
}
if (authMode === "session") {
return null;
}
}
if (authMode === "apiKey" || authMode === "both") {
return await authenticateRequest(req);
}
return null;
}
async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
req: NextRequest,
props: TProps,
schemas: S | undefined,
requestId: string,
instance: string
): Promise<
| { ok: true; parsedInput: TV3ParsedInput<S> }
| {
ok: false;
response: Response;
}
> {
const parsedInput = {} as TV3ParsedInput<S>;
if (schemas?.body) {
let bodyData: unknown;
try {
bodyData = await req.json();
} catch {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
instance,
invalid_params: [{ name: "body", reason: "Malformed JSON input, please check your request body" }],
}),
};
}
const bodyResult = schemas.body.safeParse(bodyData);
if (!bodyResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
instance,
invalid_params: formatZodIssues(bodyResult.error, "body"),
}),
};
}
parsedInput.body = bodyResult.data as TV3ParsedInput<S>["body"];
}
if (schemas?.query) {
const queryResult = schemas.query.safeParse(searchParamsToObject(req.nextUrl.searchParams));
if (!queryResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid query parameters", {
instance,
invalid_params: formatZodIssues(queryResult.error, "query"),
}),
};
}
parsedInput.query = queryResult.data as TV3ParsedInput<S>["query"];
}
if (schemas?.params) {
const paramsResult = schemas.params.safeParse(await getRouteParams(props));
if (!paramsResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid route parameters", {
instance,
invalid_params: formatZodIssues(paramsResult.error, "params"),
}),
};
}
parsedInput.params = paramsResult.data as TV3ParsedInput<S>["params"];
}
return { ok: true, parsedInput };
}
function ensureRequestIdHeader(response: Response, requestId: string): Response {
if (response.headers.get("X-Request-Id")) {
return response;
}
const headers = new Headers(response.headers);
headers.set("X-Request-Id", requestId);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
async function authenticateV3RequestOrRespond(
req: NextRequest,
authMode: TV3AuthMode,
requestId: string,
instance: string
): Promise<
{ authentication: TV3Authentication; response: null } | { authentication: null; response: Response }
> {
const authentication = await authenticateV3Request(req, authMode);
if (!authentication && authMode !== "none") {
return {
authentication: null,
response: problemUnauthorized(requestId, getUnauthenticatedDetail(authMode), instance),
};
}
return {
authentication,
response: null,
};
}
async function applyV3RateLimitOrRespond(params: {
authentication: TV3Authentication;
enabled: boolean;
config: TRateLimitConfig;
requestId: string;
log: ReturnType<typeof logger.withContext>;
}): Promise<Response | null> {
const { authentication, enabled, config, requestId, log } = params;
if (!enabled) {
return null;
}
const identifier = getRateLimitIdentifier(authentication);
if (!identifier) {
return null;
}
try {
await applyRateLimit(config, identifier);
} catch (error) {
log.warn({ error, statusCode: 429 }, "V3 API rate limit exceeded");
return problemTooManyRequests(
requestId,
error instanceof Error ? error.message : "Rate limit exceeded",
error instanceof TooManyRequestsError ? error.retryAfter : undefined
);
}
return null;
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const instance = req.nextUrl.pathname;
const log = logger.withContext({
requestId,
method: req.method,
path: instance,
});
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
if (authResult.response) {
log.warn({ statusCode: authResult.response.status }, "V3 API authentication failed");
return authResult.response;
}
const parsedInputResult = await parseV3Input(req, props, schemas, requestId, instance);
if (!parsedInputResult.ok) {
log.warn({ statusCode: parsedInputResult.response.status }, "V3 API request validation failed");
return parsedInputResult.response;
}
const rateLimitResponse = await applyV3RateLimitOrRespond({
authentication: authResult.authentication,
enabled: rateLimit,
config: customRateLimitConfig ?? rateLimitConfigs.api.v3,
requestId,
log,
});
if (rateLimitResponse) {
return rateLimitResponse;
}
const response = await handler({
req,
props,
authentication: authResult.authentication,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
return ensureRequestIdHeader(response, requestId);
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
};
};
-274
View File
@@ -1,274 +0,0 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
const requestId = "req-123";
describe("requireSessionWorkspaceAccess", () => {
test("returns 401 when authentication is null", async () => {
const result = await requireSessionWorkspaceAccess(null, "proj_abc", "read", requestId);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.status).toBe(401);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 401 when authentication is API key (no user)", async () => {
const result = await requireSessionWorkspaceAccess(
{ apiKeyId: "key_1", organizationId: "org_1", environmentPermissions: [] } as any,
"proj_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns 403 when workspace (environment) is not found (avoid leaking existence)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_nonexistent",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "read" },
],
});
});
test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"readWrite",
requestId
);
expect(result).not.toBeInstanceOf(Response);
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_abc",
organizationId: "org_1",
});
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "readWrite" },
],
});
});
});
const keyBase = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_k",
organizationAccess: { accessControl: { read: true, write: false } },
};
function envPerm(environmentId: string, permission: ApiKeyPermission = ApiKeyPermission.read) {
return {
environmentId,
environmentType: EnvironmentType.development,
projectId: "proj_k",
projectName: "K",
permission,
};
}
describe("requireV3WorkspaceAccess", () => {
beforeEach(() => {
vi.mocked(getEnvironment).mockResolvedValue({
id: "env_k",
projectId: "proj_k",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValue("org_k");
});
test("401 when authentication is null", async () => {
const r = await requireV3WorkspaceAccess(null, "env_x", "read", requestId);
expect((r as Response).status).toBe(401);
});
test("delegates to session flow when user is present", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_s",
projectId: "proj_s",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_s",
"read",
requestId
);
expect(r).toEqual({
environmentId: "env_s",
projectId: "proj_s",
organizationId: "org_s",
});
});
test("returns context for API key with read on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_a", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_a", "read", requestId);
expect(r).toEqual({
environmentId: "ws_a",
projectId: "proj_k",
organizationId: "org_k",
});
expect(getEnvironment).toHaveBeenCalledWith("ws_a");
});
test("returns context for API key with write on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_b", ApiKeyPermission.write)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_b", "read", requestId);
expect(r).toEqual({
environmentId: "ws_b",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns 403 when API key permission is lower than the required permission", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_write", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_write", "readWrite", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key has no matching environment", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("other_env")],
};
const r = await requireV3WorkspaceAccess(auth as any, "wanted", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key permission is not list-eligible (runtime value)", async () => {
const auth = {
...keyBase,
environmentPermissions: [
{
...envPerm("ws_c"),
permission: "invalid" as unknown as ApiKeyPermission,
},
],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_c", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("returns context for API key with manage on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_m", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_m", "manage", requestId);
expect(r).toEqual({
environmentId: "ws_m",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_missing", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_missing", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("401 when auth is neither session nor valid API key payload", async () => {
const r = await requireV3WorkspaceAccess({ user: {} } as any, "env", "read", requestId);
expect((r as Response).status).toBe(401);
});
});
-122
View File
@@ -1,122 +0,0 @@
/**
* V3 API auth session (browser) or API key with environment-scoped access.
*/
import { ApiKeyPermission } from "@prisma/client";
import { logger } from "@formbricks/logger";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import type { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { problemForbidden, problemUnauthorized } from "./response";
import type { TV3Authentication } from "./types";
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTeamPermission): boolean {
const grantedRank = {
[ApiKeyPermission.read]: 1,
[ApiKeyPermission.write]: 2,
[ApiKeyPermission.manage]: 3,
}[permission];
const requiredRank = {
read: 1,
readWrite: 2,
manage: 3,
}[minPermission];
return grantedRank >= requiredRank;
}
/**
* Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer.
* Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers
* use internal IDs (environmentId, projectId, organizationId) without resolving again.
* We use 403 (not 404) when the workspace is not found to avoid leaking resource existence.
*/
export async function requireSessionWorkspaceAccess(
authentication: TV3Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
// --- Session checks ---
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if (!("user" in authentication) || !authentication.user?.id) {
return problemUnauthorized(requestId, "Session required", instance);
}
const userId = authentication.user.id;
const log = logger.withContext({ requestId, workspaceId });
try {
// Resolve workspaceId → environmentId, projectId, organizationId (single place to change when Workspace exists).
const context = await resolveV3WorkspaceContext(workspaceId);
// Org + project-team access; we use internal IDs from context.
await checkAuthorizationUpdated({
userId,
organizationId: context.organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: context.projectId, minPermission },
],
});
return context;
} catch (err) {
if (err instanceof ResourceNotFoundError || err instanceof AuthorizationError) {
const message = err instanceof ResourceNotFoundError ? "Workspace not found" : "Forbidden";
log.warn({ statusCode: 403, errorCode: err.name }, message);
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw err;
}
}
/** Session or API key: authorize `workspaceId` against the resolved V3 workspace context. */
export async function requireV3WorkspaceAccess(
authentication: TV3Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if ("user" in authentication && authentication.user?.id) {
return requireSessionWorkspaceAccess(authentication, workspaceId, minPermission, requestId, instance);
}
const keyAuth = authentication as TAuthenticationApiKey;
if (keyAuth.apiKeyId && Array.isArray(keyAuth.environmentPermissions)) {
const log = logger.withContext({ requestId, workspaceId, apiKeyId: keyAuth.apiKeyId });
try {
const context = await resolveV3WorkspaceContext(workspaceId);
const permission = keyAuth.environmentPermissions.find(
(environmentPermission) => environmentPermission.environmentId === context.environmentId
);
if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) {
log.warn({ statusCode: 403 }, "API key not allowed for workspace");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return context;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: error.name }, "Workspace not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw error;
}
}
return problemUnauthorized(requestId, "Not authenticated", instance);
}
-95
View File
@@ -1,95 +0,0 @@
import { describe, expect, test } from "vitest";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
problemNotFound,
problemTooManyRequests,
problemUnauthorized,
successListResponse,
} from "./response";
describe("v3 problem responses", () => {
test("problemBadRequest includes invalid_params", async () => {
const res = problemBadRequest("rid", "bad", {
invalid_params: [{ name: "x", reason: "y" }],
instance: "/p",
});
expect(res.status).toBe(400);
expect(res.headers.get("X-Request-Id")).toBe("rid");
const body = await res.json();
expect(body.code).toBe("bad_request");
expect(body.requestId).toBe("rid");
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
expect(body.instance).toBe("/p");
});
test("problemUnauthorized default detail", async () => {
const res = problemUnauthorized("r1");
expect(res.status).toBe(401);
const body = await res.json();
expect(body.detail).toBe("Not authenticated");
expect(body.code).toBe("not_authenticated");
});
test("problemForbidden", async () => {
const res = problemForbidden("r2", undefined, "/api/x");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(body.instance).toBe("/api/x");
});
test("problemInternalError", async () => {
const res = problemInternalError("r3", "oops", "/i");
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
expect(body.detail).toBe("oops");
});
test("problemNotFound includes details", async () => {
const res = problemNotFound("r4", "Survey", "s1", "/s");
expect(res.status).toBe(404);
const body = await res.json();
expect(body.code).toBe("not_found");
expect(body.details).toEqual({ resource_type: "Survey", resource_id: "s1" });
});
test("problemTooManyRequests with Retry-After", async () => {
const res = problemTooManyRequests("r5", "slow down", 60);
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("60");
const body = await res.json();
expect(body.code).toBe("too_many_requests");
});
test("problemTooManyRequests without Retry-After", async () => {
const res = problemTooManyRequests("r6", "nope");
expect(res.headers.get("Retry-After")).toBeNull();
});
});
describe("successListResponse", () => {
test("sets X-Request-Id and default cache", async () => {
const res = successListResponse(
[{ a: 1 }],
{ limit: 10, nextCursor: "cursor-1" },
{
requestId: "req-x",
}
);
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-x");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: [{ a: 1 }],
meta: { limit: 10, nextCursor: "cursor-1" },
});
});
test("custom Cache-Control", async () => {
const res = successListResponse([], { limit: 5, nextCursor: null }, { cache: "private, max-age=0" });
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});
-149
View File
@@ -1,149 +0,0 @@
/**
* V3 API response helpers RFC 9457 Problem Details (application/problem+json)
* and list envelope for success responses.
*/
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store" as const;
export type InvalidParam = { name: string; reason: string };
export type ProblemExtension = {
code?: string;
requestId: string;
details?: Record<string, unknown>;
invalid_params?: InvalidParam[];
};
export type ProblemBody = {
type?: string;
title: string;
status: number;
detail: string;
instance?: string;
} & ProblemExtension;
function problemResponse(
status: number,
title: string,
detail: string,
requestId: string,
options?: {
type?: string;
instance?: string;
code?: string;
details?: Record<string, unknown>;
invalid_params?: InvalidParam[];
headers?: Record<string, string>;
}
): Response {
const body: ProblemBody = {
title,
status,
detail,
requestId,
...(options?.type && { type: options.type }),
...(options?.instance && { instance: options.instance }),
...(options?.code && { code: options.code }),
...(options?.details && { details: options.details }),
...(options?.invalid_params && { invalid_params: options.invalid_params }),
};
const headers: Record<string, string> = {
"Content-Type": PROBLEM_JSON,
"Cache-Control": CACHE_NO_STORE,
"X-Request-Id": requestId,
...options?.headers,
};
return Response.json(body, { status, headers });
}
export function problemBadRequest(
requestId: string,
detail: string,
options?: { invalid_params?: InvalidParam[]; instance?: string }
): Response {
return problemResponse(400, "Bad Request", detail, requestId, {
code: "bad_request",
instance: options?.instance,
invalid_params: options?.invalid_params,
});
}
export function problemUnauthorized(
requestId: string,
detail: string = "Not authenticated",
instance?: string
): Response {
return problemResponse(401, "Unauthorized", detail, requestId, {
code: "not_authenticated",
instance,
});
}
export function problemForbidden(
requestId: string,
detail: string = "You are not authorized to access this resource",
instance?: string
): Response {
return problemResponse(403, "Forbidden", detail, requestId, {
code: "forbidden",
instance,
});
}
/**
* 404 with resource details. Do not use for auth-sensitive or existence-sensitive resources:
* the body includes resource_type and resource_id, which can leak existence to unauthenticated or unauthorized callers.
* Prefer problemForbidden with a generic message for those cases.
*/
export function problemNotFound(
requestId: string,
resourceType: string,
resourceId: string | null,
instance?: string
): Response {
return problemResponse(404, "Not Found", `${resourceType} not found`, requestId, {
code: "not_found",
details: { resource_type: resourceType, resource_id: resourceId },
instance,
});
}
export function problemInternalError(
requestId: string,
detail: string = "An unexpected error occurred.",
instance?: string
): Response {
return problemResponse(500, "Internal Server Error", detail, requestId, {
code: "internal_server_error",
instance,
});
}
export function problemTooManyRequests(requestId: string, detail: string, retryAfter?: number): Response {
const headers: Record<string, string> = {};
if (retryAfter !== undefined) {
headers["Retry-After"] = String(retryAfter);
}
return problemResponse(429, "Too Many Requests", detail, requestId, {
code: "too_many_requests",
headers,
});
}
export function successListResponse<T, TMeta extends Record<string, unknown>>(
data: T[],
meta: TMeta,
options?: { requestId?: string; cache?: string }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json({ data, meta }, { status: 200, headers });
}
-4
View File
@@ -1,4 +0,0 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
@@ -1,38 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
describe("resolveV3WorkspaceContext", () => {
test("returns environmentId, projectId and organizationId when workspace exists (today: workspaceId === environmentId)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_xyz",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("env_abc");
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_xyz",
organizationId: "org_123",
});
expect(getEnvironment).toHaveBeenCalledWith("env_abc");
expect(getOrganizationIdFromProjectId).toHaveBeenCalledWith("proj_xyz");
});
test("throws when workspace (environment) does not exist", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("env_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(getOrganizationIdFromProjectId).not.toHaveBeenCalled();
});
});
@@ -1,50 +0,0 @@
/**
* V3 API workspace internal IDs translation layer (retro-compatibility / future-proofing).
*
* Workspace is the default container for surveys. We are deprecating Environment and making
* Workspace that container. In the API, workspaceId refers to that container.
*
* Today: workspaceId is mapped to environmentId (Environment is the current container for surveys).
* When Environment is deprecated and Workspace exists: resolve workspaceId to the Workspace entity
* (and derive environmentId or equivalent from it). Change only this file.
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
/**
* Internal IDs derived from a V3 workspace identifier.
* Today: environmentId is the workspace (Environment = container for surveys until Workspace exists).
*/
export type V3WorkspaceContext = {
/** Environment ID — the container for surveys today. Replaced by workspace when Environment is deprecated. */
environmentId: string;
/** Project ID used for projectTeam auth. */
projectId: string;
/** Organization ID used for org-level auth. */
organizationId: string;
};
/**
* Resolves a V3 API workspaceId to internal environmentId, projectId, and organizationId.
* Today: workspaceId is treated as environmentId (workspace = container for surveys = Environment).
*
* @throws ResourceNotFoundError if the workspace (environment) does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
// Today: workspaceId is the environment id (survey container). Look it up.
const environment = await getEnvironment(workspaceId);
if (!environment) {
throw new ResourceNotFoundError("environment", workspaceId);
}
// Derive org for auth; project comes from the environment.
const organizationId = await getOrganizationIdFromProjectId(environment.projectId);
// We looked up by workspaceId (as environment id), so the resolved environment id is workspaceId.
return {
environmentId: workspaceId,
projectId: environment.projectId,
organizationId,
};
}
@@ -1,122 +0,0 @@
import { describe, expect, test } from "vitest";
import { collectMultiValueQueryParam, parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
const wid = "clxx1234567890123456789012";
function params(qs: string): URLSearchParams {
return new URLSearchParams(qs);
}
describe("collectMultiValueQueryParam", () => {
test("merges repeated keys and comma-separated values", () => {
const sp = params("status=draft&status=inProgress&type=link,app");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft", "inProgress"]);
expect(collectMultiValueQueryParam(sp, "type")).toEqual(["link", "app"]);
});
test("dedupes", () => {
const sp = params("status=draft&status=draft");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft"]);
});
});
describe("parseV3SurveysListQuery", () => {
test("rejects unsupported query parameters like filterCriteria", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filterCriteria={}`));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.invalid_params[0].name).toBe("filterCriteria");
});
test("rejects unknown query parameters", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&foo=bar`));
expect(r.ok).toBe(false);
if (!r.ok)
expect(r.invalid_params[0]).toEqual({
name: "foo",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
});
test("rejects the legacy after query parameter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&after=legacy-cursor`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "after",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("rejects the legacy flat name query parameter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&name=Foo`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "name",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("parses minimal query", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}`));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.limit).toBe(20);
expect(r.cursor).toBeNull();
expect(r.sortBy).toBe("updatedAt");
expect(r.filterCriteria).toBeUndefined();
}
});
test("builds filter from explicit operator params", () => {
const r = parseV3SurveysListQuery(
params(
`workspaceId=${wid}&filter[name][contains]=Foo&filter[status][in]=inProgress&filter[status][in]=draft&filter[type][in]=link&sortBy=updatedAt`
)
);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.filterCriteria).toEqual({
name: "Foo",
status: ["inProgress", "draft"],
type: ["link"],
});
expect(r.sortBy).toBe("updatedAt");
}
});
test("invalid status", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filter[status][in]=notastatus`));
expect(r.ok).toBe(false);
});
test("rejects the createdBy filter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filter[createdBy][in]=you`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "filter[createdBy][in]",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("rejects an invalid cursor", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&cursor=not-a-real-cursor`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params).toEqual([
{
name: "cursor",
reason: "The cursor is invalid.",
},
]);
}
});
});
@@ -1,159 +0,0 @@
/**
* Validates GET /api/v3/surveys query string and builds {@link TSurveyFilterCriteria} for list/count.
* Keeps HTTP parsing separate from the route handler and shared survey list service.
*/
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
type TSurveyFilterCriteria,
ZSurveyFilters,
ZSurveyStatus,
ZSurveyType,
} from "@formbricks/types/surveys/types";
import {
type TSurveyListPageCursor,
type TSurveyListSort,
decodeSurveyListPageCursor,
normalizeSurveyListSort,
} from "@/modules/survey/list/lib/survey-page";
const V3_SURVEYS_DEFAULT_LIMIT = 20;
const V3_SURVEYS_MAX_LIMIT = 100;
const FILTER_NAME_CONTAINS_QUERY_PARAM = "filter[name][contains]" as const;
const FILTER_STATUS_IN_QUERY_PARAM = "filter[status][in]" as const;
const FILTER_TYPE_IN_QUERY_PARAM = "filter[type][in]" as const;
const SUPPORTED_QUERY_PARAMS = [
"workspaceId",
"limit",
"cursor",
FILTER_NAME_CONTAINS_QUERY_PARAM,
FILTER_STATUS_IN_QUERY_PARAM,
FILTER_TYPE_IN_QUERY_PARAM,
"sortBy",
] as const;
const SUPPORTED_QUERY_PARAM_SET = new Set<string>(SUPPORTED_QUERY_PARAMS);
type InvalidParam = { name: string; reason: string };
/** Collect repeated query keys and comma-separated values for operator-style filters. */
export function collectMultiValueQueryParam(searchParams: URLSearchParams, key: string): string[] {
const acc: string[] = [];
for (const raw of searchParams.getAll(key)) {
for (const part of raw.split(",")) {
const t = part.trim();
if (t) acc.push(t);
}
}
return [...new Set(acc)];
}
const ZV3SurveysListQuery = z.object({
workspaceId: ZId,
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
cursor: z.string().min(1).optional(),
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z
.string()
.max(512)
.optional()
.transform((s) => (s === undefined || s.trim() === "" ? undefined : s.trim())),
[FILTER_STATUS_IN_QUERY_PARAM]: z.array(ZSurveyStatus).optional(),
[FILTER_TYPE_IN_QUERY_PARAM]: z.array(ZSurveyType).optional(),
sortBy: ZSurveyFilters.shape.sortBy.optional(),
});
export type TV3SurveysListQuery = z.infer<typeof ZV3SurveysListQuery>;
export type TV3SurveysListQueryParseResult =
| {
ok: true;
workspaceId: string;
limit: number;
cursor: TSurveyListPageCursor | null;
sortBy: TSurveyListSort;
filterCriteria: TSurveyFilterCriteria | undefined;
}
| { ok: false; invalid_params: InvalidParam[] };
function getUnsupportedQueryParams(searchParams: URLSearchParams): InvalidParam[] {
const unsupportedParams = [
...new Set(Array.from(searchParams.keys()).filter((key) => !SUPPORTED_QUERY_PARAM_SET.has(key))),
];
return unsupportedParams.map((name) => ({
name,
reason: `Unsupported query parameter. Use only ${SUPPORTED_QUERY_PARAMS.join(", ")}.`,
}));
}
function buildFilterCriteria(q: TV3SurveysListQuery): TSurveyFilterCriteria | undefined {
const f: TSurveyFilterCriteria = {};
if (q[FILTER_NAME_CONTAINS_QUERY_PARAM]) f.name = q[FILTER_NAME_CONTAINS_QUERY_PARAM];
if (q[FILTER_STATUS_IN_QUERY_PARAM]?.length) f.status = q[FILTER_STATUS_IN_QUERY_PARAM];
if (q[FILTER_TYPE_IN_QUERY_PARAM]?.length) f.type = q[FILTER_TYPE_IN_QUERY_PARAM];
return Object.keys(f).length > 0 ? f : undefined;
}
export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3SurveysListQueryParseResult {
const unsupportedQueryParams = getUnsupportedQueryParams(searchParams);
if (unsupportedQueryParams.length > 0) {
return {
ok: false,
invalid_params: unsupportedQueryParams,
};
}
const statusVals = collectMultiValueQueryParam(searchParams, FILTER_STATUS_IN_QUERY_PARAM);
const typeVals = collectMultiValueQueryParam(searchParams, FILTER_TYPE_IN_QUERY_PARAM);
const raw = {
workspaceId: searchParams.get("workspaceId"),
limit: searchParams.get("limit") ?? undefined,
cursor: searchParams.get("cursor")?.trim() || undefined,
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
sortBy: searchParams.get("sortBy")?.trim() || undefined,
};
const result = ZV3SurveysListQuery.safeParse(raw);
if (!result.success) {
return {
ok: false,
invalid_params: result.error.issues.map((issue) => ({
name: issue.path.join(".") || "query",
reason: issue.message,
})),
};
}
const q = result.data;
const sortBy = normalizeSurveyListSort(q.sortBy);
let cursor: TSurveyListPageCursor | null = null;
if (q.cursor) {
try {
cursor = decodeSurveyListPageCursor(q.cursor, sortBy);
} catch (error) {
return {
ok: false,
invalid_params: [
{
name: "cursor",
reason: error instanceof Error ? error.message : "The cursor is invalid.",
},
],
};
}
}
return {
ok: true,
workspaceId: q.workspaceId,
limit: q.limit,
cursor,
sortBy,
filterCriteria: buildFilterCriteria(q),
};
}
-357
View File
@@ -1,357 +0,0 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/survey-page", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey-page")>();
return {
...actual,
getSurveyListPage: vi.fn(),
};
});
vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey")>();
return {
...actual,
getSurveyCount: vi.fn(),
};
});
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const validWorkspaceId = "clxx1234567890123456789012";
const resolvedEnvironmentId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) headers["x-request-id"] = requestId;
return new NextRequest(url, { headers });
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: false },
},
environmentPermissions: [
{
environmentId: validWorkspaceId,
environmentType: EnvironmentType.development,
projectId: "proj_1",
projectName: "P",
permission: ApiKeyPermission.read,
},
],
};
describe("GET /api/v3/surveys", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(requireV3WorkspaceAccess).mockImplementation(async (auth, workspaceId) => {
if (auth && "apiKeyId" in auth) {
const p = auth.environmentPermissions.find((e) => e.environmentId === workspaceId);
if (!p) {
return new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
);
}
return {
environmentId: workspaceId,
projectId: p.projectId,
organizationId: auth.organizationId,
};
}
return {
environmentId: resolvedEnvironmentId,
projectId: "proj_1",
organizationId: "org_1",
};
});
vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [], nextCursor: null });
vi.mocked(getSurveyCount).mockResolvedValue(0);
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(401);
expect(res.headers.get("Content-Type")).toBe("application/problem+json");
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 200 with session and valid workspaceId", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-456");
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(res.headers.get("X-Request-Id")).toBe("req-456");
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
validWorkspaceId,
"read",
"req-456",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("returns 200 with x-api-key when workspace is on the key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-k", {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
validWorkspaceId,
"read",
"req-k",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(validWorkspaceId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(validWorkspaceId, undefined);
});
test("returns 403 when API key does not include workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue({
...apiKeyAuth,
environmentPermissions: [
{
environmentId: "claa1111111111111111111111",
environmentType: EnvironmentType.development,
projectId: "proj_x",
projectName: "X",
permission: ApiKeyPermission.read,
},
],
} as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, undefined, {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("returns 400 when the createdBy filter is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[createdBy][in]=you`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.invalid_params?.some((p: { name: string }) => p.name === "filter[createdBy][in]")).toBe(true);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is missing", async () => {
const req = createRequest("http://localhost/api/v3/surveys");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is not cuid2", async () => {
const req = createRequest("http://localhost/api/v3/surveys?workspaceId=not-a-cuid");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("returns 400 when limit exceeds max", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=101`);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("reflects limit, nextCursor, and totalCount in meta", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [],
nextCursor: "cursor-123",
});
vi.mocked(getSurveyCount).mockResolvedValue(42);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=10`);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta).toEqual({ limit: 10, nextCursor: "cursor-123", totalCount: 42 });
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 10,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("passes filter query to getSurveyListPage", async () => {
const filterCriteria = { status: ["inProgress"] };
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[status][in]=inProgress&sortBy=updatedAt`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, filterCriteria);
});
test("returns 400 when filterCriteria is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filterCriteria=${encodeURIComponent("{}")}`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 403 when auth returns 403", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-789",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("list items expose workspaceId instead of environmentId and omit internal fields", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [
{
id: "s1",
name: "Survey 1",
environmentId: "env_1",
type: "link",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
responseCount: 0,
creator: { name: "Test" },
singleUse: null,
} as any,
],
nextCursor: null,
});
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("environmentId");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("env_1");
});
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new ResourceNotFoundError("survey", "s1"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-nf");
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
});
test("returns 500 when getSurveyListPage throws DatabaseError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new DatabaseError("db down"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-db");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 500 on unexpected error from getSurveyListPage", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new Error("boom"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-err");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
});
-81
View File
@@ -1,81 +0,0 @@
/**
* GET /api/v3/surveys list surveys for a workspace.
* Session cookie or x-api-key; scope by workspaceId only.
*/
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successListResponse,
} from "@/app/api/v3/lib/response";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
import { serializeV3SurveyListItem } from "./serializers";
export const GET = withV3ApiWrapper({
auth: "both",
handler: async ({ req, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId });
try {
const searchParams = new URL(req.url).searchParams;
const parsed = parseV3SurveysListQuery(searchParams);
if (!parsed.ok) {
log.warn({ statusCode: 400, invalidParams: parsed.invalid_params }, "Validation failed");
return problemBadRequest(requestId, "Invalid query parameters", {
invalid_params: parsed.invalid_params,
instance,
});
}
const authResult = await requireV3WorkspaceAccess(
authentication,
parsed.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const { environmentId } = authResult;
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
getSurveyListPage(environmentId, {
limit: parsed.limit,
cursor: parsed.cursor,
sortBy: parsed.sortBy,
filterCriteria: parsed.filterCriteria,
}),
getSurveyCount(environmentId, parsed.filterCriteria),
]);
return successListResponse(
surveys.map(serializeV3SurveyListItem),
{
limit: parsed.limit,
nextCursor,
totalCount,
},
{ requestId, cache: "private, no-store" }
);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (err instanceof DatabaseError) {
log.error({ error: err, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error: err, statusCode: 500 }, "V3 surveys list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -1,18 +0,0 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
workspaceId: string;
};
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { environmentId, singleUse: _omitSingleUse, ...rest } = survey;
return {
...rest,
workspaceId: environmentId,
};
}
+19 -49
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { getIsActiveCustomerAction } from "./actions";
interface ChatwootWidgetProps { interface ChatwootWidgetProps {
chatwootBaseUrl: string; chatwootBaseUrl: string;
@@ -13,18 +12,6 @@ interface ChatwootWidgetProps {
const CHATWOOT_SCRIPT_ID = "chatwoot-script"; const CHATWOOT_SCRIPT_ID = "chatwoot-script";
interface ChatwootInstance {
setUser: (
userId: string,
userInfo: {
email?: string | null;
name?: string | null;
}
) => void;
setCustomAttributes: (attributes: Record<string, unknown>) => void;
reset: () => void;
}
export const ChatwootWidget = ({ export const ChatwootWidget = ({
userEmail, userEmail,
userName, userName,
@@ -33,14 +20,15 @@ export const ChatwootWidget = ({
chatwootBaseUrl, chatwootBaseUrl,
}: ChatwootWidgetProps) => { }: ChatwootWidgetProps) => {
const userSetRef = useRef(false); const userSetRef = useRef(false);
const customerStatusSetRef = useRef(false);
const getChatwoot = useCallback((): ChatwootInstance | null => {
return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null;
}, []);
const setUserInfo = useCallback(() => { const setUserInfo = useCallback(() => {
const $chatwoot = getChatwoot(); const $chatwoot = (
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
if (userId && $chatwoot && !userSetRef.current) { if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, { $chatwoot.setUser(userId, {
email: userEmail, email: userEmail,
@@ -48,19 +36,7 @@ export const ChatwootWidget = ({
}); });
userSetRef.current = true; userSetRef.current = true;
} }
}, [userId, userEmail, userName, getChatwoot]); }, [userId, userEmail, userName]);
const setCustomerStatus = useCallback(async () => {
if (customerStatusSetRef.current) return;
const $chatwoot = getChatwoot();
if (!$chatwoot) return;
const response = await getIsActiveCustomerAction();
if (response?.data !== undefined) {
$chatwoot.setCustomAttributes({ isActiveCustomer: response.data });
}
customerStatusSetRef.current = true;
}, [getChatwoot]);
useEffect(() => { useEffect(() => {
if (!chatwootWebsiteToken) return; if (!chatwootWebsiteToken) return;
@@ -89,19 +65,23 @@ export const ChatwootWidget = ({
const handleChatwootReady = () => setUserInfo(); const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady); globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
const handleChatwootOpen = () => setCustomerStatus();
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
// Check if Chatwoot is already ready // Check if Chatwoot is already ready
if (getChatwoot()) { if (
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
setUserInfo(); setUserInfo();
} }
return () => { return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady); globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
const $chatwoot = getChatwoot(); const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
if ($chatwoot) { if ($chatwoot) {
$chatwoot.reset(); $chatwoot.reset();
} }
@@ -110,18 +90,8 @@ export const ChatwootWidget = ({
scriptElement?.remove(); scriptElement?.remove();
userSetRef.current = false; userSetRef.current = false;
customerStatusSetRef.current = false;
}; };
}, [ }, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
chatwootBaseUrl,
chatwootWebsiteToken,
userId,
userEmail,
userName,
setUserInfo,
setCustomerStatus,
getChatwoot,
]);
return null; return null;
}; };
-18
View File
@@ -1,18 +0,0 @@
"use server";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => {
const paidBillingPlans = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
const organizations = await getOrganizationsByUserId(ctx.user.id);
return organizations.some((organization) => {
const stripe = organization.billing.stripe;
const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false;
const isActiveSubscription =
stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing";
return isPaidPlan && isActiveSubscription;
});
});
@@ -1,21 +0,0 @@
import type { TUserLocale } from "@formbricks/types/user";
import { getTranslate } from "@/lingodotdev/server";
interface NoScriptWarningProps {
locale: TUserLocale;
}
export const NoScriptWarning = async ({ locale }: NoScriptWarningProps) => {
const t = await getTranslate(locale);
return (
<noscript>
<div className="fixed inset-0 z-[9999] flex h-dvh w-full items-center justify-center bg-slate-50">
<div className="rounded-xl border border-slate-200 bg-white p-8 text-center shadow-lg">
<h1 className="mb-4 text-2xl font-bold text-slate-800">{t("common.javascript_required")}</h1>
<p className="text-slate-600">{t("common.javascript_required_description")}</p>
</div>
</div>
</noscript>
);
};
-2
View File
@@ -1,6 +1,5 @@
import { Metadata } from "next"; import { Metadata } from "next";
import React from "react"; import React from "react";
import { NoScriptWarning } from "@/app/components/NoScriptWarning";
import { SentryProvider } from "@/app/sentry/SentryProvider"; import { SentryProvider } from "@/app/sentry/SentryProvider";
import { import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
@@ -27,7 +26,6 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return ( return (
<html lang={locale} translate="no"> <html lang={locale} translate="no">
<body className="flex h-dvh flex-col transition-all ease-in-out"> <body className="flex h-dvh flex-col transition-all ease-in-out">
<NoScriptWarning locale={locale} />
<SentryProvider <SentryProvider
sentryDsn={SENTRY_DSN} sentryDsn={SENTRY_DSN}
sentryRelease={SENTRY_RELEASE} sentryRelease={SENTRY_RELEASE}
@@ -421,38 +421,6 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
}); });
test("uses unauthenticatedResponse when provided instead of default 401", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Session,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue(null);
const custom401 = new Response(JSON.stringify({ title: "Custom", status: 401 }), {
status: 401,
headers: { "Content-Type": "application/problem+json" },
});
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/api/v3/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({
handler,
unauthenticatedResponse: () => custom401,
});
const res = await wrapped(req, undefined);
expect(res).toBe(custom401);
expect(handler).not.toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
});
test("handles rate limiting errors", async () => { test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers"); const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
+1 -11
View File
@@ -38,11 +38,6 @@ export interface TWithV1ApiWrapperParams<TResult extends { response: Response },
action?: TAuditAction; action?: TAuditAction;
targetType?: TAuditTarget; targetType?: TAuditTarget;
customRateLimitConfig?: TRateLimitConfig; customRateLimitConfig?: TRateLimitConfig;
/**
* When the route requires auth but the client is unauthenticated, the wrapper normally returns
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
*/
unauthenticatedResponse?: (req: NextRequest) => Response;
} }
enum ApiV1RouteTypeEnum { enum ApiV1RouteTypeEnum {
@@ -270,7 +265,7 @@ const getRouteType = (
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>( export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => { ): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params; const { handler, action, targetType, customRateLimitConfig } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => { return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup === // === Audit Log Setup ===
const saveAuditLog = action && targetType; const saveAuditLog = action && targetType;
@@ -292,11 +287,6 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
const authentication = await handleAuthentication(authenticationMethod, req); const authentication = await handleAuthentication(authenticationMethod, req);
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) { if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
if (unauthenticatedResponse) {
const res = unauthenticatedResponse(req);
await processResponse(res, req, auditLog);
return res;
}
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
@@ -90,17 +90,6 @@ describe("endpoint-validator", () => {
}); });
describe("isManagementApiRoute", () => { describe("isManagementApiRoute", () => {
test("should return Both for v3 surveys routes", () => {
expect(isManagementApiRoute("/api/v3/surveys")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
expect(isManagementApiRoute("/api/v3/surveys/clxxxxxxxxxxxxxxxxxxxxxxxx")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
});
test("should return correct object for management API routes with API key authentication", () => { test("should return correct object for management API routes with API key authentication", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toEqual({ expect(isManagementApiRoute("/api/v1/management/something")).toEqual({
isManagementApi: true, isManagementApi: true,
@@ -22,9 +22,6 @@ export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; i
export const isManagementApiRoute = ( export const isManagementApiRoute = (
url: string url: string
): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => { ): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => {
// V3 surveys: session cookie or x-api-key (same pattern as management storage)
if (/^\/api\/v3\/surveys(?:\/|$)/.test(url))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/management/storage")) if (url.includes("/api/v1/management/storage"))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both }; return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/webhooks")) if (url.includes("/api/v1/webhooks"))
+27 -55
View File
@@ -140,7 +140,6 @@ checksums:
common/connect: 8778ee245078a8be4a2ce855c8c56edc common/connect: 8778ee245078a8be4a2ce855c8c56edc
common/connect_formbricks: a9dd747575e7e035da69251366df6f95 common/connect_formbricks: a9dd747575e7e035da69251366df6f95
common/connected: aa0ceca574641de34c74b9e590664230 common/connected: aa0ceca574641de34c74b9e590664230
common/contact: 9afa39bc47019ee6dec6c74b6273967c
common/contacts: d5b6c3f890b3904eaf5754081945c03d common/contacts: d5b6c3f890b3904eaf5754081945c03d
common/continue: 3cfba90b4600131e82fc4260c568d044 common/continue: 3cfba90b4600131e82fc4260c568d044
common/copied: 29208e06d704c4fc4b8b534dc7acc4ef common/copied: 29208e06d704c4fc4b8b534dc7acc4ef
@@ -148,7 +147,6 @@ checksums:
common/copy: 627c00d2c850b9b45f7341a6ac01b6bb common/copy: 627c00d2c850b9b45f7341a6ac01b6bb
common/copy_code: 704c13d9bc01caad29a1cf3179baa111 common/copy_code: 704c13d9bc01caad29a1cf3179baa111
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35 common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
common/copy_to_environment: c482d26b8fd4962af6542bbf49e49a32
common/count_attributes: 48805e836a9b50f9635ad00fed953058 common/count_attributes: 48805e836a9b50f9635ad00fed953058
common/count_contacts: 9f71d503455264f1eec1ae58894cf143 common/count_contacts: 9f71d503455264f1eec1ae58894cf143
common/count_members: 31ce64ca63fdf95e02ab5543b6e2f717 common/count_members: 31ce64ca63fdf95e02ab5543b6e2f717
@@ -188,12 +186,12 @@ checksums:
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
common/edit: eee7f39ff90b18852afc1671f21fbaa9 common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/elements: 8cb054d952b341e5965284860d532bc7
common/email: e7f34943a0c2fb849db1839ff6ef5cb5 common/email: e7f34943a0c2fb849db1839ff6ef5cb5
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22 common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97 common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d common/enterprise_license: e81bf506f47968870c7bd07245648a0d
common/environment: 0844e8dc1485339c8de066dc0a9bb6a1 common/environment: 0844e8dc1485339c8de066dc0a9bb6a1
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
common/environment_notice: 228a8668be1812e031f438d166861729 common/environment_notice: 228a8668be1812e031f438d166861729
common/error: 3c95bcb32c2104b99a46f5b3dd015248 common/error: 3c95bcb32c2104b99a46f5b3dd015248
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015 common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
@@ -230,13 +228,11 @@ checksums:
common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76 common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76
common/integration: 40d02f65c4356003e0e90ffb944907d2 common/integration: 40d02f65c4356003e0e90ffb944907d2
common/integrations: 0ccce343287704cd90150c32e2fcad36 common/integrations: 0ccce343287704cd90150c32e2fcad36
common/invalid_date_with_value: f7f9dbe99f25f1724367ee57572b52bf common/invalid_date: 4c18c82f7317d4a02f8d5fef611e82b7
common/invalid_file_name: 8243c91b898110fb15ebb24aa6a7d313 common/invalid_file_name: 8243c91b898110fb15ebb24aa6a7d313
common/invalid_file_type: f0c83e7d61dbad8250abb59869af4b9e common/invalid_file_type: f0c83e7d61dbad8250abb59869af4b9e
common/invite: 181884cea804cbde665f160811ee7ad0 common/invite: 181884cea804cbde665f160811ee7ad0
common/invite_them: d4b7aadbd3c924b04ad4fce419709f10 common/invite_them: d4b7aadbd3c924b04ad4fce419709f10
common/javascript_required: d7988e5934af4d0df54fda369c0e4fb6
common/javascript_required_description: 4b65f456db79af4898888a3dd034fe2f
common/key: 3d1065ab98a1c2f1210507fd5c7bf515 common/key: 3d1065ab98a1c2f1210507fd5c7bf515
common/label: a5c71bf158481233f8215dbd38cc196b common/label: a5c71bf158481233f8215dbd38cc196b
common/language: 277fd1a41cc237a437cd1d5e4a80463b common/language: 277fd1a41cc237a437cd1d5e4a80463b
@@ -257,9 +253,7 @@ checksums:
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8 common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
common/members: 0932e80cba1e3e0a7f52bb67ff31da32 common/members: 0932e80cba1e3e0a7f52bb67ff31da32
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08 common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership: 83c856bbc2af99d8c3d860959d1d2a85
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4 common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/meta: 842eac888f134f3525f8ea613d933687
common/metadata: 695d4f7da261ba76e3be4de495491028 common/metadata: 695d4f7da261ba76e3be4de495491028
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72 common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2 common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
@@ -273,7 +267,6 @@ checksums:
common/new: 126d036fae5fb6b629728ecb97e6195b common/new: 126d036fae5fb6b629728ecb97e6195b
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
common/next: 89ddbcf710eba274963494f312bdc8a9 common/next: 89ddbcf710eba274963494f312bdc8a9
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4 common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
@@ -299,9 +292,10 @@ checksums:
common/or: 7b133c38bec0d5ee23cc6bcf9a8de50b common/or: 7b133c38bec0d5ee23cc6bcf9a8de50b
common/organization: 3dc8489af7e74121f65ce6d9677bc94d common/organization: 3dc8489af7e74121f65ce6d9677bc94d
common/organization_id: ef09b71c84a25b5da02a23c77e68a335 common/organization_id: ef09b71c84a25b5da02a23c77e68a335
common/organization_not_found: 4cb8c07ec2c599b6f48750e06ffa182b
common/organization_settings: 11528aa89ae9935e55dcb54478058775 common/organization_settings: 11528aa89ae9935e55dcb54478058775
common/organization_teams_not_found: ce29fcb7a4e8b4582f92b65dea9b7d4e
common/other: 79acaa6cd481262bea4e743a422529d2 common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/others: 39160224ce0e35eb4eb252c997edf4d8 common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5 common/overview: 30c54e4dc4ce599b87d94be34a8617f5
@@ -318,7 +312,6 @@ checksums:
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50 common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2 common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08 common/privacy: 7459744a63ef8af4e517a09024bd7c08
@@ -360,7 +353,6 @@ checksums:
common/select: 5ac04c47a98deb85906bc02e0de91ab0 common/select: 5ac04c47a98deb85906bc02e0de91ab0
common/select_all: eedc7cdb02de467c15dc418a066a77f2 common/select_all: eedc7cdb02de467c15dc418a066a77f2
common/select_filter: c50082c3981f1161022f9787a19aed71 common/select_filter: c50082c3981f1161022f9787a19aed71
common/select_language: d75cf5fbce8a4c7a9055e2210af74480
common/select_survey: bac52e59c7847417bef6fe7b7096b475 common/select_survey: bac52e59c7847417bef6fe7b7096b475
common/select_teams: ae5d451929846ae6367562bc671a1af9 common/select_teams: ae5d451929846ae6367562bc671a1af9
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56 common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
@@ -373,6 +365,7 @@ checksums:
common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79 common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79
common/shown: 63e4ffb245c05e04b636446c3dbdd8df common/shown: 63e4ffb245c05e04b636446c3dbdd8df
common/size: 227fadeeff951e041ff42031a11a4626 common/size: 227fadeeff951e041ff42031a11a4626
common/skip: b7f28dfa2f58b80b149bb82b392d0291
common/skipped: d496f0f667e1b4364b954db71335d4ef common/skipped: d496f0f667e1b4364b954db71335d4ef
common/skips: 99de7579122a3fa6ec5e2a47f3fd8b34 common/skips: 99de7579122a3fa6ec5e2a47f3fd8b34
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0 common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
@@ -392,6 +385,7 @@ checksums:
common/survey_id: 08303e98b3d4134947256e494b0c829e common/survey_id: 08303e98b3d4134947256e494b0c829e
common/survey_languages: 93e4a10ab190e6b1e1f7fe5f702df249 common/survey_languages: 93e4a10ab190e6b1e1f7fe5f702df249
common/survey_live: d1f370505c67509e7b2759952daba20d common/survey_live: d1f370505c67509e7b2759952daba20d
common/survey_not_found: 0485ea98d13a414eeefc8f1118b9c293
common/survey_paused: c770d174d6b57e8425a54906a09c8b39 common/survey_paused: c770d174d6b57e8425a54906a09c8b39
common/survey_type: 417fcfecf8eaedefc4f11172426811f9 common/survey_type: 417fcfecf8eaedefc4f11172426811f9
common/surveys: 33f68ad4111b32a6361beb9d5c184533 common/surveys: 33f68ad4111b32a6361beb9d5c184533
@@ -406,6 +400,7 @@ checksums:
common/team_name: 549d949de4b9adad4afd6427a60a329e common/team_name: 549d949de4b9adad4afd6427a60a329e
common/team_role: 66db395781aef64ef3791417b3b67c0b common/team_role: 66db395781aef64ef3791417b3b67c0b
common/teams: b63448c05270497973ac4407047dae02 common/teams: b63448c05270497973ac4407047dae02
common/teams_not_found: 02f333a64a83c1c014d8900ec9666345
common/text: 4ddccc1974775ed7357f9beaf9361cec common/text: 4ddccc1974775ed7357f9beaf9361cec
common/time: b504a03d52e8001bfdc5cb6205364f42 common/time: b504a03d52e8001bfdc5cb6205364f42
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
@@ -429,6 +424,7 @@ checksums:
common/url: ca97457614226960d41dd18c3c29c86b common/url: ca97457614226960d41dd18c3c29c86b
common/user: 61073457a5c3901084b557d065f876be common/user: 61073457a5c3901084b557d065f876be
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4 common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
common/variable: c13db5775ba9791b1522cc55c9c7acce common/variable: c13db5775ba9791b1522cc55c9c7acce
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4 common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
common/variables: ffd3eec5497af36d7b4e4185bad1313a common/variables: ffd3eec5497af36d7b4e4185bad1313a
@@ -443,13 +439,15 @@ checksums:
common/website_survey: 17513d25a07b6361768a15ec622b021b common/website_survey: 17513d25a07b6361768a15ec622b021b
common/weeks: 545de30df4f44d3f6d1d344af6a10815 common/weeks: 545de30df4f44d3f6d1d344af6a10815
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7 common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387 common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1 common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf
common/workspace_id: bafef925e1b57b52a69844fdf47aac3c common/workspace_id: bafef925e1b57b52a69844fdf47aac3c
common/workspace_name: 14c04a902a874ab5ddbe9cf369ef0414 common/workspace_name: 14c04a902a874ab5ddbe9cf369ef0414
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1 common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
common/workspace_not_found: 038fb0aaf3570610f4377b9eaed13752
common/workspace_permission_not_found: e94bdff8af51175c5767714f82bb4833
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2 common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
@@ -625,6 +623,7 @@ checksums:
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09 environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8 environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
@@ -804,16 +803,9 @@ checksums:
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9 environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405 environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
environments/integrations/webhooks/endpoint_bad_gateway_error: 48ab17e9a77030b289ec22f497f50b63
environments/integrations/webhooks/endpoint_gateway_timeout_error: 5da45e2f6933927d1f8b0aaa9566e6a6
environments/integrations/webhooks/endpoint_internal_server_error: 6773fc34349febf95475cde88d8ee072
environments/integrations/webhooks/endpoint_method_not_allowed_error: 9963b503311393f4d7bffae9df46d422
environments/integrations/webhooks/endpoint_not_found_error: 607b75b7b7aa92ca81fe44e466f7c318
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6 environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027 environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
environments/integrations/webhooks/endpoint_service_unavailable_error: f9d4874c322f2963f5afaede354c9416
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39 environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469 environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
@@ -1020,25 +1012,6 @@ checksums:
environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12 environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12
environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44 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/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_instance_mismatch_description: 00f47e33ff54fca52ce9b125cd77fda5
environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526 environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526
environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8 environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8
@@ -1068,13 +1041,6 @@ checksums:
environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112 environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112
environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea
environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43 environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43
environments/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
environments/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
environments/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
environments/settings/general/ai_enabled_description: e8c3e9f362588898a6cea85e18c013a1
environments/settings/general/ai_settings_updated_successfully: 2a6f534dc3a246ced46becd8a4a9543d
environments/settings/general/ai_smart_tools_enabled: 1dda984f5262c5f9120ee9a409236758
environments/settings/general/ai_smart_tools_enabled_description: 1ceca6707746d3ab4a530712a06d91da
environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37 environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
environments/settings/general/cannot_delete_only_organization: 833cc6848b28f2694a4552b4de91a6ba environments/settings/general/cannot_delete_only_organization: 833cc6848b28f2694a4552b4de91a6ba
environments/settings/general/cannot_leave_only_organization: dd8463262e4299fef7ad73512225c55b environments/settings/general/cannot_leave_only_organization: dd8463262e4299fef7ad73512225c55b
@@ -1355,6 +1321,7 @@ checksums:
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429 environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2 environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04 environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
environments/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8 environments/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
@@ -1392,7 +1359,6 @@ checksums:
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7 environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113 environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413 environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
environments/surveys/edit/expand_preview: 6b694829e05432b9b54e7da53bc5be2f
environments/surveys/edit/external_urls_paywall_tooltip: 427f29bbbec18ebf8b3ea8d0253ddd66 environments/surveys/edit/external_urls_paywall_tooltip: 427f29bbbec18ebf8b3ea8d0253ddd66
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722 environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350 environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350
@@ -1616,8 +1582,6 @@ checksums:
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9 environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802 environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
environments/surveys/edit/reverse_order_occasionally: 170fd50de940f382fa2e605228e4e088
environments/surveys/edit/reverse_order_occasionally_except_last: 1c833001b940f1419dd7534b199a0b4a
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4 environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813 environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
@@ -1646,7 +1610,6 @@ checksums:
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0 environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197 environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288 environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288
environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7 environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7
environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b
@@ -1662,12 +1625,10 @@ checksums:
environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69 environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69
environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0 environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0
environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486 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_heading: dae5ac4a02a886dc9d9fc40927091919
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2 environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11 environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
environments/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579 environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
@@ -2464,8 +2425,8 @@ checksums:
templates/csat_question_1_headline: bd4894e95695ce5bc9fc5d326c79bc90 templates/csat_question_1_headline: bd4894e95695ce5bc9fc5d326c79bc90
templates/csat_question_1_lower_label: 54d464343c0bc17231fd51aa2d73623f templates/csat_question_1_lower_label: 54d464343c0bc17231fd51aa2d73623f
templates/csat_question_1_upper_label: 9f000f63949d875ae628fc354a2a7f6a templates/csat_question_1_upper_label: 9f000f63949d875ae628fc354a2a7f6a
templates/csat_question_2_choice_1: 0cb1260dd25e94f56c2da7ab21b0e0ae templates/csat_question_2_choice_1: a0cf57bc571c95c43924a3c641d1355e
templates/csat_question_2_choice_2: f12ed9d98c7965ab949efcc25f8ca85e templates/csat_question_2_choice_2: a3a49eb9cc86972bce6dc41a107f472d
templates/csat_question_2_choice_3: a7c58d9b8afdaefadeb1f5fdf4d5ad3f templates/csat_question_2_choice_3: a7c58d9b8afdaefadeb1f5fdf4d5ad3f
templates/csat_question_2_choice_4: d09723c4bc1d85d99c2a9248ed0d4578 templates/csat_question_2_choice_4: d09723c4bc1d85d99c2a9248ed0d4578
templates/csat_question_2_choice_5: a89ca2602a3322e89adf17b3349e03ab templates/csat_question_2_choice_5: a89ca2602a3322e89adf17b3349e03ab
@@ -2936,7 +2897,7 @@ checksums:
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72 templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0 templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
templates/preview_survey_question_open_text_headline: 573f1b04b79f672ad42ba5e54320a940 templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830 templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00 templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
@@ -3187,3 +3148,14 @@ checksums:
templates/usability_question_9_headline: 5850229e97ae97698ce90b330ea49682 templates/usability_question_9_headline: 5850229e97ae97698ce90b330ea49682
templates/usability_rating_description: 8c4f3818fe830ae544611f816265f1a1 templates/usability_rating_description: 8c4f3818fe830ae544611f816265f1a1
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2 templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
workflows/follow_up_label: 8cafe669370271035aeac8e8cab0f123
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
workflows/placeholder: f5d943582bf25e8734930844e598457b
workflows/subheading: ebf5e3b3aeb85e13e843358cc5476f42
workflows/submit_button: 7a062f2de02ce60b1d73e510ff1ca094
workflows/thank_you_description: 7623c1ba4f059c8d9e68aae3360b20b1
workflows/thank_you_title: 07edd8c50685a52c0969d711df26d768
-97
View File
@@ -1,97 +0,0 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { upsertAccount } from "./service";
const { mockUpsert } = vi.hoisted(() => ({
mockUpsert: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
upsert: mockUpsert,
},
},
}));
describe("account service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("upsertAccount keeps user ownership immutable on update", async () => {
const accountData = {
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
};
mockUpsert.mockResolvedValue({
id: "account-1",
createdAt: new Date(),
updatedAt: new Date(),
...accountData,
});
await upsertAccount(accountData);
expect(mockUpsert).toHaveBeenCalledWith({
where: {
provider_providerAccountId: {
provider: "google",
providerAccountId: "provider-1",
},
},
create: accountData,
update: {
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
},
});
});
test("upsertAccount wraps Prisma known request errors", async () => {
const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), {
message: "duplicate account",
});
mockUpsert.mockRejectedValue(prismaError);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toMatchObject({
name: "DatabaseError",
message: "duplicate account",
});
});
test("upsertAccount rethrows non-Prisma errors", async () => {
const error = new Error("unexpected failure");
mockUpsert.mockRejectedValue(error);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toThrow("unexpected failure");
});
});
-33
View File
@@ -20,36 +20,3 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
throw error; throw error;
} }
}; };
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
access_token: validatedAccountData.access_token,
refresh_token: validatedAccountData.refresh_token,
expires_at: validatedAccountData.expires_at,
scope: validatedAccountData.scope,
token_type: validatedAccountData.token_type,
id_token: validatedAccountData.id_token,
};
try {
const account = await prisma.account.upsert({
where: {
provider_providerAccountId: {
provider: validatedAccountData.provider,
providerAccountId: validatedAccountData.providerAccountId,
},
},
create: validatedAccountData,
update: updateAccountData,
});
return account;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
-3
View File
@@ -26,10 +26,7 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL; export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS; export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1"; export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES;
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1"; export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET); export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
-77
View File
@@ -1,77 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const ORIGINAL_ENV = process.env;
const setTestEnv = (overrides: Record<string, string | undefined> = {}) => {
process.env = {
...ORIGINAL_ENV,
NODE_ENV: "test",
DATABASE_URL: "https://example.com/db",
ENCRYPTION_KEY: "12345678901234567890123456789012",
...overrides,
};
};
describe("env", () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
process.env = ORIGINAL_ENV;
});
test("uses the default password reset token lifetime when env var is not set", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: undefined,
});
const { env } = await import("./env");
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(30);
});
test("uses the configured password reset token lifetime", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "45",
});
const { env } = await import("./env");
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(45);
});
test("fails to load when the password reset token lifetime is not an integer", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "30minutes",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("fails to load when the password reset token lifetime is out of range", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "121",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("allows enabling DEBUG_SHOW_RESET_LINK", async () => {
setTestEnv({
DEBUG_SHOW_RESET_LINK: "1",
});
const { env } = await import("./env");
expect(env.DEBUG_SHOW_RESET_LINK).toBe("1");
});
test("fails to load when DEBUG_SHOW_RESET_LINK is invalid", async () => {
setTestEnv({
DEBUG_SHOW_RESET_LINK: "true",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
});
-6
View File
@@ -15,9 +15,7 @@ export const env = createEnv({
BREVO_API_KEY: z.string().optional(), BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(), BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(), DATABASE_URL: z.url(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(), DEBUG: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(), AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(), AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
E2E_TESTING: z.enum(["1", "0"]).optional(), E2E_TESTING: z.enum(["1", "0"]).optional(),
@@ -62,7 +60,6 @@ export const env = createEnv({
? z.string().optional() ? z.string().optional()
: z.url("REDIS_URL is required for caching, rate limiting, and audit logging"), : z.url("REDIS_URL is required for caching, rate limiting, and audit logging"),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(), PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: z.coerce.number().int().min(5).max(120).optional().default(30),
PRIVACY_URL: z PRIVACY_URL: z
.url() .url()
.optional() .optional()
@@ -144,9 +141,7 @@ export const env = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID, BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET, CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL, DATABASE_URL: process.env.DATABASE_URL,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG, DEBUG: process.env.DEBUG,
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID, AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO, AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
E2E_TESTING: process.env.E2E_TESTING, E2E_TESTING: process.env.E2E_TESTING,
@@ -186,7 +181,6 @@ export const env = createEnv({
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM, OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
REDIS_URL: process.env.REDIS_URL, REDIS_URL: process.env.REDIS_URL,
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED, PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: process.env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES,
PRIVACY_URL: process.env.PRIVACY_URL, PRIVACY_URL: process.env.PRIVACY_URL,
RATE_LIMITING_DISABLED: process.env.RATE_LIMITING_DISABLED, RATE_LIMITING_DISABLED: process.env.RATE_LIMITING_DISABLED,
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY, S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
+1 -3
View File
@@ -84,9 +84,7 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => { export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
if (!surveyLanguages?.length || !languageCode) return "default"; if (!surveyLanguages?.length || !languageCode) return "default";
const language = surveyLanguages.find( const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
(surveyLanguage) => surveyLanguage.language.code.toLowerCase() === languageCode.toLowerCase()
);
return language?.default ? "default" : language?.language.code || "default"; return language?.default ? "default" : language?.language.code || "default";
}; };
+2 -2
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import "server-only"; import "server-only";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; import { AuthorizationError } from "@formbricks/types/errors";
import { getOrganizationByEnvironmentId } from "../../organization/service"; import { getOrganizationByEnvironmentId } from "../../organization/service";
import { getMembershipByUserIdOrganizationId } from "../service"; import { getMembershipByUserIdOrganizationId } from "../service";
@@ -9,7 +9,7 @@ export const getMembershipByUserIdOrganizationIdAction = async (environmentId: s
const organization = await getOrganizationByEnvironmentId(environmentId); const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError("Organization", null); throw new Error("Organization not found");
} }
const currentUserMembership = await getMembershipRole(userId, organization.id); const currentUserMembership = await getMembershipRole(userId, organization.id);
+1 -2
View File
@@ -37,8 +37,7 @@ describe("auth", () => {
}, },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAISmartToolsEnabled: false, isAIEnabled: false,
isAIDataAnalysisEnabled: false,
}, },
]; ];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations); vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
+5 -10
View File
@@ -72,8 +72,7 @@ describe("Organization Service", () => {
stripeCustomerId: null, stripeCustomerId: null,
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAISmartToolsEnabled: false, isAIEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false, whitelabel: false,
}; };
@@ -125,8 +124,7 @@ describe("Organization Service", () => {
stripeCustomerId: null, stripeCustomerId: null,
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAISmartToolsEnabled: false, isAIEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false, whitelabel: false,
}, },
]; ];
@@ -178,8 +176,7 @@ describe("Organization Service", () => {
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
billing: expectedBilling, billing: expectedBilling,
isAISmartToolsEnabled: false, isAIEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false, whitelabel: false,
}; };
@@ -238,8 +235,7 @@ describe("Organization Service", () => {
stripeCustomerId: null, stripeCustomerId: null,
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAISmartToolsEnabled: false, isAIEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false, whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }], memberships: [{ userId: "user1" }, { userId: "user2" }],
projects: [ projects: [
@@ -280,8 +276,7 @@ describe("Organization Service", () => {
stripeCustomerId: null, stripeCustomerId: null,
usageCycleAnchor: expect.any(Date), usageCycleAnchor: expect.any(Date),
}, },
isAISmartToolsEnabled: false, isAIEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false, whitelabel: false,
}); });
expect(prisma.organization.update).toHaveBeenCalledWith({ expect(prisma.organization.update).toHaveBeenCalledWith({
+2 -4
View File
@@ -34,8 +34,7 @@ export const select = {
stripe: true, stripe: true,
}, },
}, },
isAISmartToolsEnabled: true, isAIEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true, whitelabel: true,
} satisfies Prisma.OrganizationSelect; } satisfies Prisma.OrganizationSelect;
@@ -73,8 +72,7 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
updatedAt: organization.updatedAt, updatedAt: organization.updatedAt,
name: organization.name, name: organization.name,
billing: mapOrganizationBilling(organization.billing), billing: mapOrganizationBilling(organization.billing),
isAISmartToolsEnabled: organization.isAISmartToolsEnabled, isAIEnabled: organization.isAIEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
whitelabel: organization.whitelabel as TOrganization["whitelabel"], whitelabel: organization.whitelabel as TOrganization["whitelabel"],
}); });
+1 -1
View File
@@ -378,7 +378,7 @@ export const getResponseDownloadFile = async (
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId); const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
if (!organizationId) { if (!organizationId) {
throw new ResourceNotFoundError("Organization", null); throw new Error("Organization ID not found");
} }
const organizationBilling = await getOrganizationBilling(organizationId); const organizationBilling = await getOrganizationBilling(organizationId);
+1 -2
View File
@@ -232,8 +232,7 @@ export const mockOrganizationOutput: TOrganization = {
name: "mock Organization", name: "mock Organization",
createdAt: currentDate, createdAt: currentDate,
updatedAt: currentDate, updatedAt: currentDate,
isAISmartToolsEnabled: false, isAIEnabled: false,
isAIDataAnalysisEnabled: false,
billing: { billing: {
stripeCustomerId: null, stripeCustomerId: null,
limits: { limits: {
+55 -28
View File
@@ -1,13 +1,62 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { import {
convertDateString,
convertDateTimeString,
convertDateTimeStringShort,
convertDatesInObject, convertDatesInObject,
convertTimeString,
formatDate, formatDate,
getTodaysDateFormatted,
getTodaysDateTimeFormatted, getTodaysDateTimeFormatted,
timeSince, timeSince,
timeSinceDate, timeSinceDate,
} from "./time"; } from "./time";
describe("Time Utilities", () => { describe("Time Utilities", () => {
describe("convertDateString", () => {
test("should format date string correctly", () => {
expect(convertDateString("2024-03-20:12:30:00")).toBe("Mar 20, 2024");
});
test("should return empty string for empty input", () => {
expect(convertDateString("")).toBe("");
});
test("should return null for null input", () => {
expect(convertDateString(null as any)).toBe(null);
});
test("should handle invalid date strings", () => {
expect(convertDateString("not-a-date")).toBe("Invalid Date");
});
});
describe("convertDateTimeString", () => {
test("should format date and time string correctly", () => {
expect(convertDateTimeString("2024-03-20T15:30:00")).toBe("Wednesday, March 20, 2024 at 3:30 PM");
});
test("should return empty string for empty input", () => {
expect(convertDateTimeString("")).toBe("");
});
});
describe("convertDateTimeStringShort", () => {
test("should format date and time string in short format", () => {
expect(convertDateTimeStringShort("2024-03-20T15:30:00")).toBe("March 20, 2024 at 3:30 PM");
});
test("should return empty string for empty input", () => {
expect(convertDateTimeStringShort("")).toBe("");
});
});
describe("convertTimeString", () => {
test("should format time string correctly", () => {
expect(convertTimeString("2024-03-20T15:30:45")).toBe("3:30:45 PM");
});
});
describe("timeSince", () => { describe("timeSince", () => {
test("should format time since in English", () => { test("should format time since in English", () => {
const now = new Date(); const now = new Date();
@@ -26,18 +75,6 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan"); expect(timeSince(oneHourAgo.toISOString(), "sv-SE")).toBe("ungefär en timme sedan");
}); });
test("should format time since in Brazilian Portuguese", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "pt-BR")).toBe("há cerca de 1 hora");
});
test("should format time since in European Portuguese", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSince(oneHourAgo.toISOString(), "pt-PT")).toBe("há aproximadamente 1 hora");
});
}); });
describe("timeSinceDate", () => { describe("timeSinceDate", () => {
@@ -46,12 +83,6 @@ describe("Time Utilities", () => {
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSinceDate(oneHourAgo)).toBe("about 1 hour ago"); expect(timeSinceDate(oneHourAgo)).toBe("about 1 hour ago");
}); });
test("should format time since from Date object in the provided locale", () => {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
expect(timeSinceDate(oneHourAgo, "de-DE")).toBe("vor etwa 1 Stunde");
});
}); });
describe("formatDate", () => { describe("formatDate", () => {
@@ -59,17 +90,13 @@ describe("Time Utilities", () => {
const date = new Date(2024, 2, 20); // March is month 2 (0-based) const date = new Date(2024, 2, 20); // March is month 2 (0-based)
expect(formatDate(date)).toBe("March 20, 2024"); expect(formatDate(date)).toBe("March 20, 2024");
}); });
});
test("should format date with the provided locale", () => { describe("getTodaysDateFormatted", () => {
const date = new Date(2024, 2, 20); test("should format today's date with specified separator", () => {
const today = new Date();
expect(formatDate(date, "de-DE")).toBe( const expected = today.toISOString().split("T")[0].split("-").join(".");
new Intl.DateTimeFormat("de-DE", { expect(getTodaysDateFormatted(".")).toBe(expected);
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
}); });
}); });
+120 -27
View File
@@ -1,33 +1,120 @@
import { type Locale, formatDistance } from "date-fns"; import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale"; import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "./utils/datetime";
const DEFAULT_LOCALE: TUserLocale = "en-US"; export const convertDateString = (dateString: string | null) => {
const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = { if (dateString === null) return null;
"de-DE": de, if (!dateString) {
"en-US": enUS, return dateString;
"es-ES": es, }
"fr-FR": fr,
"hu-HU": hu, const date = new Date(dateString);
"ja-JP": ja, if (isNaN(date.getTime())) {
"nl-NL": nl, return "Invalid Date";
"pt-BR": ptBR, }
"pt-PT": pt, return intlFormat(
"ro-RO": ro, date,
"ru-RU": ru, {
"sv-SE": sv, year: "numeric",
"zh-Hans-CN": zhCN, month: "short",
"zh-Hant-TW": zhTW, day: "numeric",
},
{
locale: "en",
}
);
}; };
const isUserLocale = (locale: string): locale is TUserLocale => Object.hasOwn(TIME_SINCE_LOCALES, locale); export const convertDateTimeString = (dateString: string) => {
if (!dateString) {
return dateString;
}
const date = new Date(dateString);
return intlFormat(
date,
{
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
};
/** Maps locale strings to date-fns locales and falls back to English for unsupported inputs. */ export const convertDateTimeStringShort = (dateString: string) => {
const getLocaleForTimeSince = (locale: string): Locale => if (!dateString) {
isUserLocale(locale) ? TIME_SINCE_LOCALES[locale] : enUS; return dateString;
}
const date = new Date(dateString);
return intlFormat(
date,
{
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
},
{
locale: "en",
}
);
};
export const timeSince = (dateString: string, locale: string = DEFAULT_LOCALE) => { export const convertTimeString = (dateString: string) => {
const date = new Date(dateString);
return intlFormat(
date,
{
hour: "numeric",
minute: "2-digit",
second: "2-digit",
},
{
locale: "en",
}
);
};
const getLocaleForTimeSince = (locale: TUserLocale) => {
switch (locale) {
case "de-DE":
return de;
case "en-US":
return enUS;
case "es-ES":
return es;
case "fr-FR":
return fr;
case "hu-HU":
return hu;
case "ja-JP":
return ja;
case "nl-NL":
return nl;
case "pt-BR":
return ptBR;
case "pt-PT":
return pt;
case "ro-RO":
return ro;
case "ru-RU":
return ru;
case "sv-SE":
return sv;
case "zh-Hans-CN":
return zhCN;
case "zh-Hant-TW":
return zhTW;
}
};
export const timeSince = (dateString: string, locale: TUserLocale) => {
const date = new Date(dateString); const date = new Date(dateString);
return formatDistance(date, new Date(), { return formatDistance(date, new Date(), {
addSuffix: true, addSuffix: true,
@@ -35,21 +122,27 @@ export const timeSince = (dateString: string, locale: string = DEFAULT_LOCALE) =
}); });
}; };
export const timeSinceDate = (date: Date, locale: string = DEFAULT_LOCALE) => { export const timeSinceDate = (date: Date) => {
return formatDistance(date, new Date(), { return formatDistance(date, new Date(), {
addSuffix: true, addSuffix: true,
locale: getLocaleForTimeSince(locale),
}); });
}; };
export const formatDate = (date: Date, locale: string = DEFAULT_LOCALE) => { export const formatDate = (date: Date) => {
return formatDateForDisplay(date, locale, { return intlFormat(date, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}); });
}; };
export const getTodaysDateFormatted = (seperator: string) => {
const date = new Date();
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
return formattedDate;
};
export const getTodaysDateTimeFormatted = (seperator: string) => { export const getTodaysDateTimeFormatted = (seperator: string) => {
const date = new Date(); const date = new Date();
const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator); const formattedDate = date.toISOString().split("T")[0].split("-").join(seperator);
+2 -4
View File
@@ -70,8 +70,7 @@ describe("User Service", () => {
}, },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAISmartToolsEnabled: false, isAIEnabled: false,
isAIDataAnalysisEnabled: false,
}, },
{ {
id: "org2", id: "org2",
@@ -88,8 +87,7 @@ describe("User Service", () => {
}, },
usageCycleAnchor: new Date(), usageCycleAnchor: new Date(),
}, },
isAISmartToolsEnabled: false, isAIEnabled: false,
isAIDataAnalysisEnabled: false,
}, },
]; ];
@@ -6,9 +6,7 @@ import {
AuthenticationError, AuthenticationError,
AuthorizationError, AuthorizationError,
EXPECTED_ERROR_NAMES, EXPECTED_ERROR_NAMES,
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
InvalidInputError, InvalidInputError,
InvalidPasswordResetTokenError,
OperationNotAllowedError, OperationNotAllowedError,
ResourceNotFoundError, ResourceNotFoundError,
TooManyRequestsError, TooManyRequestsError,
@@ -73,7 +71,6 @@ describe("isExpectedError (shared helper)", () => {
"AuthenticationError", "AuthenticationError",
"OperationNotAllowedError", "OperationNotAllowedError",
"TooManyRequestsError", "TooManyRequestsError",
"InvalidPasswordResetTokenError",
]; ];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length); expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
@@ -90,7 +87,6 @@ describe("isExpectedError (shared helper)", () => {
{ ErrorClass: InvalidInputError, args: ["Invalid input"] }, { ErrorClass: InvalidInputError, args: ["Invalid input"] },
{ ErrorClass: ValidationError, args: ["Invalid data"] }, { ErrorClass: ValidationError, args: ["Invalid data"] },
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] }, { ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => { ])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args); const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true); expect(isExpectedError(error)).toBe(true);
@@ -178,14 +174,6 @@ describe("actionClient handleServerError", () => {
expect(result?.serverError).toBe("Not allowed"); expect(result?.serverError).toBe("Not allowed");
expect(Sentry.captureException).not.toHaveBeenCalled(); expect(Sentry.captureException).not.toHaveBeenCalled();
}); });
test("InvalidPasswordResetTokenError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(
new InvalidPasswordResetTokenError(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE)
);
expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
expect(Sentry.captureException).not.toHaveBeenCalled();
});
}); });
describe("unexpected errors SHOULD be reported to Sentry", () => { describe("unexpected errors SHOULD be reported to Sentry", () => {
-67
View File
@@ -1,67 +0,0 @@
import { describe, expect, test } from "vitest";
import { type TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatStoredDateForDisplay, getSurveyDateFormatMap, parseStoredDateValue } from "./date-display";
describe("date display utils", () => {
test("parses ISO stored dates", () => {
const parsedDate = parseStoredDateValue("2025-05-06");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("parses legacy stored dates using the element format", () => {
const parsedDate = parseStoredDateValue("5-6-2025", "M-d-y");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("parses day-first stored dates when no format is provided", () => {
const parsedDate = parseStoredDateValue("06-05-2025");
expect(parsedDate).not.toBeNull();
expect(parsedDate?.getFullYear()).toBe(2025);
expect(parsedDate?.getMonth()).toBe(4);
expect(parsedDate?.getDate()).toBe(6);
});
test("formats stored dates using the selected locale", () => {
const date = new Date(2025, 4, 6);
expect(formatStoredDateForDisplay("2025-05-06", undefined, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
test("returns null for invalid stored dates", () => {
expect(formatStoredDateForDisplay("2025-02-30", "y-M-d")).toBeNull();
});
test("builds a date format map for survey date elements", () => {
const elements = [
{
id: "dateQuestion",
type: "date",
format: "d-M-y",
},
{
id: "textQuestion",
type: "openText",
},
] as TSurveyElement[];
expect(getSurveyDateFormatMap(elements)).toEqual({
dateQuestion: "d-M-y",
});
});
});
-85
View File
@@ -1,85 +0,0 @@
import type { TSurveyDateElement, TSurveyElement } from "@formbricks/types/surveys/elements";
import { formatDateWithOrdinal } from "./datetime";
export type TSurveyDateFormatMap = Partial<Record<string, TSurveyDateElement["format"]>>;
const ISO_STORED_DATE_PATTERN = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
const buildDate = (year: number, month: number, day: number): Date | null => {
if ([year, month, day].some((value) => Number.isNaN(value))) {
return null;
}
const parsedDate = new Date(year, month - 1, day);
if (
parsedDate.getFullYear() !== year ||
parsedDate.getMonth() !== month - 1 ||
parsedDate.getDate() !== day
) {
return null;
}
return parsedDate;
};
const parseLegacyStoredDateValue = (value: string, format: TSurveyDateElement["format"]): Date | null => {
const parts = value.split("-");
if (parts.length !== 3 || parts.some((part) => !/^\d{1,4}$/.test(part))) {
return null;
}
const [first, second, third] = parts.map(Number);
switch (format) {
case "M-d-y":
return buildDate(third, first, second);
case "d-M-y":
return buildDate(third, second, first);
case "y-M-d":
return buildDate(first, second, third);
}
};
export const parseStoredDateValue = (value: string, format?: TSurveyDateElement["format"]): Date | null => {
const isoMatch = ISO_STORED_DATE_PATTERN.exec(value);
if (isoMatch) {
return buildDate(Number(isoMatch[1]), Number(isoMatch[2]), Number(isoMatch[3]));
}
if (format) {
return parseLegacyStoredDateValue(value, format);
}
if (/^\d{1,2}-\d{1,2}-\d{4}$/.test(value)) {
return parseLegacyStoredDateValue(value, "d-M-y");
}
return null;
};
export const formatStoredDateForDisplay = (
value: string,
format: TSurveyDateElement["format"] | undefined,
locale: string = "en-US"
): string | null => {
const parsedDate = parseStoredDateValue(value, format);
if (!parsedDate) {
return null;
}
return formatDateWithOrdinal(parsedDate, locale);
};
export const getSurveyDateFormatMap = (elements: TSurveyElement[]): TSurveyDateFormatMap => {
return elements.reduce<TSurveyDateFormatMap>((dateFormats, element) => {
if (element.type === "date") {
dateFormats[element.id] = element.format;
}
return dateFormats;
}, {});
};
+4 -43
View File
@@ -1,12 +1,5 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { import { diffInDays, formatDateWithOrdinal, getFormattedDateTimeString, isValidDateString } from "./datetime";
diffInDays,
formatDateForDisplay,
formatDateTimeForDisplay,
formatDateWithOrdinal,
getFormattedDateTimeString,
isValidDateString,
} from "./datetime";
describe("datetime utils", () => { describe("datetime utils", () => {
test("diffInDays calculates the difference in days between two dates", () => { test("diffInDays calculates the difference in days between two dates", () => {
@@ -15,45 +8,13 @@ describe("datetime utils", () => {
expect(diffInDays(date1, date2)).toBe(5); expect(diffInDays(date1, date2)).toBe(5);
}); });
test("formatDateWithOrdinal formats a date using the provided locale", () => { test("formatDateWithOrdinal formats a date with ordinal suffix", () => {
// Create a date that's fixed to May 6, 2025 at noon UTC // Create a date that's fixed to May 6, 2025 at noon UTC
// Using noon ensures the date won't change in most timezones // Using noon ensures the date won't change in most timezones
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0)); const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
expect(formatDateWithOrdinal(date)).toBe( // Test the function
new Intl.DateTimeFormat("en-US", { expect(formatDateWithOrdinal(date)).toBe("Tuesday, May 6th, 2025");
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date)
);
});
test("formatDateForDisplay uses the provided locale", () => {
const date = new Date(Date.UTC(2025, 4, 6, 12, 0, 0));
expect(formatDateForDisplay(date, "de-DE")).toBe(
new Intl.DateTimeFormat("de-DE", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date)
);
});
test("formatDateTimeForDisplay uses the provided locale", () => {
const date = new Date(Date.UTC(2025, 4, 6, 12, 30, 0));
expect(formatDateTimeForDisplay(date, "fr-FR")).toBe(
new Intl.DateTimeFormat("fr-FR", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date)
);
}); });
test("isValidDateString validates correct date strings", () => { test("isValidDateString validates correct date strings", () => {
+13 -44
View File
@@ -1,17 +1,7 @@
const DEFAULT_LOCALE = "en-US"; const getOrdinalSuffix = (day: number) => {
const suffixes = ["th", "st", "nd", "rd"];
const DEFAULT_DATE_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = { const relevantDigits = day < 30 ? day % 20 : day % 30;
year: "numeric", return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
month: "short",
day: "numeric",
};
const DEFAULT_DATE_TIME_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}; };
// Helper function to calculate difference in days between two dates // Helper function to calculate difference in days between two dates
@@ -20,44 +10,23 @@ export const diffInDays = (date1: Date, date2: Date) => {
return Math.floor(diffTime / (1000 * 60 * 60 * 24)); return Math.floor(diffTime / (1000 * 60 * 60 * 24));
}; };
export const formatDateForDisplay = ( export const formatDateWithOrdinal = (date: Date, locale: string = "en-US"): string => {
date: Date, const dayOfWeek = new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date);
locale: string = DEFAULT_LOCALE, const day = date.getDate();
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_DISPLAY_OPTIONS const month = new Intl.DateTimeFormat(locale, { month: "long" }).format(date);
): string => { const year = date.getFullYear();
return new Intl.DateTimeFormat(locale, options).format(date); return `${dayOfWeek}, ${month} ${day}${getOrdinalSuffix(day)}, ${year}`;
};
export const formatDateTimeForDisplay = (
date: Date,
locale: string = DEFAULT_LOCALE,
options: Intl.DateTimeFormatOptions = DEFAULT_DATE_TIME_DISPLAY_OPTIONS
): string => {
return new Intl.DateTimeFormat(locale, options).format(date);
};
export const formatDateWithOrdinal = (date: Date, locale: string = DEFAULT_LOCALE): string => {
return formatDateForDisplay(date, locale, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}; };
export const isValidDateString = (value: string) => { export const isValidDateString = (value: string) => {
const regex = /^(?:\d{4}-\d{1,2}-\d{1,2}|\d{1,2}-\d{1,2}-\d{4})$/; const regex = /^(?:\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4})$/;
if (!regex.test(value)) { if (!regex.test(value)) {
return false; return false;
} }
const normalizedValue = /^\d{1,2}-\d{1,2}-\d{4}$/.test(value) const date = new Date(value);
? value.replace(/(\d{1,2})-(\d{1,2})-(\d{4})/, "$3-$2-$1") return date;
: value;
const date = new Date(normalizedValue);
return !Number.isNaN(date.getTime());
}; };
export const getFormattedDateTimeString = (date: Date): string => { export const getFormattedDateTimeString = (date: Date): string => {
+10 -24
View File
@@ -32,17 +32,16 @@ vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))), structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
})); }));
vi.mock("@/lib/utils/date-display", () => ({ vi.mock("@/lib/utils/datetime", () => ({
formatStoredDateForDisplay: vi.fn((value: string, format: string | undefined, locale: string) => { isValidDateString: vi.fn((value) => {
if (value === "2023-01-01") { try {
return `formatted-${locale}-${format ?? "iso"}`; return !isNaN(new Date(value as string).getTime());
} catch {
return false;
} }
}),
if (value === "01-02-2023" && format === "M-d-y") { formatDateWithOrdinal: vi.fn(() => {
return `legacy-${locale}-${format}`; return "January 1st, 2023";
}
return null;
}), }),
})); }));
@@ -478,20 +477,7 @@ describe("recall utility functions", () => {
}; };
const result = parseRecallInfo(text, responseData); const result = parseRecallInfo(text, responseData);
expect(result).toBe("You joined on formatted-en-US-iso"); expect(result).toBe("You joined on January 1st, 2023");
});
test("formats legacy date values using the provided locale and stored format", () => {
const text = "You joined on #recall:joinDate/fallback:an-unknown-date#";
const responseData: TResponseData = {
joinDate: "01-02-2023",
};
const result = parseRecallInfo(text, responseData, undefined, false, "fr-FR", {
joinDate: "M-d-y",
});
expect(result).toBe("You joined on legacy-fr-FR-M-d-y");
}); });
test("formats array values as comma-separated list", () => { test("formats array values as comma-separated list", () => {
+7 -11
View File
@@ -6,7 +6,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils"; import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone"; import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { type TSurveyDateFormatMap, formatStoredDateForDisplay } from "./date-display"; import { formatDateWithOrdinal, isValidDateString } from "./datetime";
export interface fallbacks { export interface fallbacks {
[id: string]: string; [id: string]: string;
@@ -224,9 +224,7 @@ export const parseRecallInfo = (
text: string, text: string,
responseData?: TResponseData, responseData?: TResponseData,
variables?: TResponseVariables, variables?: TResponseVariables,
withSlash: boolean = false, withSlash: boolean = false
locale: string = "en-US",
dateFormats?: TSurveyDateFormatMap
) => { ) => {
let modifiedText = text; let modifiedText = text;
const questionIds = responseData ? Object.keys(responseData) : []; const questionIds = responseData ? Object.keys(responseData) : [];
@@ -256,14 +254,12 @@ export const parseRecallInfo = (
value = responseData[recallItemId]; value = responseData[recallItemId];
// Apply formatting for special value types // Apply formatting for special value types
if (typeof value === "string") { if (value) {
const formattedDate = formatStoredDateForDisplay(value, dateFormats?.[recallItemId], locale); if (isValidDateString(value as string)) {
value = formatDateWithOrdinal(new Date(value as string));
if (formattedDate) { } else if (Array.isArray(value)) {
value = formattedDate; value = value.filter((item) => item).join(", ");
} }
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", ");
} }
} }
@@ -9,10 +9,6 @@ vi.mock("node:dns", () => ({
}, },
})); }));
vi.mock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
}));
const mockResolve = vi.mocked(dns.resolve); const mockResolve = vi.mocked(dns.resolve);
const mockResolve6 = vi.mocked(dns.resolve6); const mockResolve6 = vi.mocked(dns.resolve6);
@@ -298,78 +294,4 @@ describe("validateWebhookUrl", () => {
}); });
}); });
}); });
describe("DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS", () => {
test("allows private IP URLs when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://127.0.0.1/")).resolves.toBeUndefined();
await expect(validateWithFlag("http://192.168.1.1/test")).resolves.toBeUndefined();
await expect(validateWithFlag("http://10.0.0.1/webhook")).resolves.toBeUndefined();
});
test("allows localhost when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost/webhook")).resolves.toBeUndefined();
await expect(validateWithFlag("http://localhost:3333/webhook")).resolves.toBeUndefined();
});
test("allows localhost.localdomain when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost.localdomain/path")).resolves.toBeUndefined();
});
test("allows hostname resolving to private IP when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(["192.168.1.1"]);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://internal.company.com/webhook")).resolves.toBeUndefined();
});
test("still rejects unresolvable hostnames when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(null, null);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://typo-gibberish.invalid/hook")).rejects.toThrow(
"Could not resolve webhook URL hostname"
);
});
test("still rejects invalid URL format when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("not-a-url")).rejects.toThrow("Invalid webhook URL format");
});
test("still rejects non-HTTP protocols when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("ftp://192.168.1.1/")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
});
}); });
+6 -16
View File
@@ -1,7 +1,6 @@
import "server-only"; import "server-only";
import dns from "node:dns"; import dns from "node:dns";
import { InvalidInputError } from "@formbricks/types/errors"; import { InvalidInputError } from "@formbricks/types/errors";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
const BLOCKED_HOSTNAMES = new Set([ const BLOCKED_HOSTNAMES = new Set([
"localhost", "localhost",
@@ -140,10 +139,8 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
const hostname = parsed.hostname; const hostname = parsed.hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) { if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) { throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
} }
// Direct IP literal — validate without DNS resolution // Direct IP literal — validate without DNS resolution
@@ -152,17 +149,12 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
if (isIPv4Literal || isIPv6Literal) { if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname; const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) { if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses"); throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
} }
return; return;
} }
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
return;
}
// Domain name — resolve DNS and validate every resolved IP // Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[]; let resolvedIPs: string[];
try { try {
@@ -176,11 +168,9 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
); );
} }
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) { for (const ip of resolvedIPs) {
for (const ip of resolvedIPs) { if (isPrivateIP(ip)) {
if (isPrivateIP(ip)) { throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
} }
} }
}; };
+30 -56
View File
@@ -167,7 +167,6 @@
"connect": "Verbinden", "connect": "Verbinden",
"connect_formbricks": "Formbricks verbinden", "connect_formbricks": "Formbricks verbinden",
"connected": "Verbunden", "connected": "Verbunden",
"contact": "Kontakt",
"contacts": "Kontakte", "contacts": "Kontakte",
"continue": "Weitermachen", "continue": "Weitermachen",
"copied": "Kopiert", "copied": "Kopiert",
@@ -175,7 +174,6 @@
"copy": "Kopieren", "copy": "Kopieren",
"copy_code": "Code kopieren", "copy_code": "Code kopieren",
"copy_link": "Link kopieren", "copy_link": "Link kopieren",
"copy_to_environment": "In {{environment}} kopieren",
"count_attributes": "{count, plural, one {{count} Attribut} other {{count} Attribute}}", "count_attributes": "{count, plural, one {{count} Attribut} other {{count} Attribute}}",
"count_contacts": "{count, plural, one {{count} Kontakt} other {{count} Kontakte}}", "count_contacts": "{count, plural, one {{count} Kontakt} other {{count} Kontakte}}",
"count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}", "count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(Kopie {copyNumber})", "duplicate_copy_number": "(Kopie {copyNumber})",
"e_commerce": "E-Commerce", "e_commerce": "E-Commerce",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"elements": "Elemente",
"email": "E-Mail", "email": "E-Mail",
"ending_card": "Abschluss-Karte", "ending_card": "Abschluss-Karte",
"enter_url": "URL eingeben", "enter_url": "URL eingeben",
"enterprise_license": "Enterprise Lizenz", "enterprise_license": "Enterprise Lizenz",
"environment": "Umgebung", "environment": "Umgebung",
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.", "environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
"error": "Fehler", "error": "Fehler",
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.", "error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Inaktive Umfragen", "inactive_surveys": "Inaktive Umfragen",
"integration": "Integration", "integration": "Integration",
"integrations": "Integrationen", "integrations": "Integrationen",
"invalid_date_with_value": "Ungültiges Datum: {value}", "invalid_date": "Ungültiges Datum",
"invalid_file_name": "Ungültiger Dateiname, bitte benennen Sie Ihre Datei um und versuchen Sie es erneut", "invalid_file_name": "Ungültiger Dateiname, bitte benennen Sie Ihre Datei um und versuchen Sie es erneut",
"invalid_file_type": "Ungültiger Dateityp", "invalid_file_type": "Ungültiger Dateityp",
"invite": "Einladen", "invite": "Einladen",
"invite_them": "Lade sie ein", "invite_them": "Lade sie ein",
"javascript_required": "JavaScript erforderlich",
"javascript_required_description": "Formbricks benötigt JavaScript, um ordnungsgemäß zu funktionieren. Bitte aktiviere JavaScript in deinen Browsereinstellungen, um fortzufahren.",
"key": "Schlüssel", "key": "Schlüssel",
"label": "Bezeichnung", "label": "Bezeichnung",
"language": "Sprache", "language": "Sprache",
@@ -284,9 +280,7 @@
"marketing": "Marketing", "marketing": "Marketing",
"members": "Mitglieder", "members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams", "members_and_teams": "Mitglieder & Teams",
"membership": "Mitgliedschaft",
"membership_not_found": "Mitgliedschaft nicht gefunden", "membership_not_found": "Mitgliedschaft nicht gefunden",
"meta": "Meta",
"metadata": "Metadaten", "metadata": "Metadaten",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.", "mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!", "mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
@@ -300,7 +294,6 @@
"new": "Neu", "new": "Neu",
"new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!", "new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!",
"next": "Weiter", "next": "Weiter",
"no_actions_found": "Keine Aktionen gefunden",
"no_background_image_found": "Kein Hintergrundbild gefunden.", "no_background_image_found": "Kein Hintergrundbild gefunden.",
"no_code": "No Code", "no_code": "No Code",
"no_files_uploaded": "Keine Dateien hochgeladen", "no_files_uploaded": "Keine Dateien hochgeladen",
@@ -326,9 +319,10 @@
"or": "oder", "or": "oder",
"organization": "Organisation", "organization": "Organisation",
"organization_id": "Organisations-ID", "organization_id": "Organisations-ID",
"organization_not_found": "Organisation nicht gefunden",
"organization_settings": "Organisationseinstellungen", "organization_settings": "Organisationseinstellungen",
"organization_teams_not_found": "Organisations-Teams nicht gefunden",
"other": "Andere", "other": "Andere",
"other_filters": "Weitere Filter",
"others": "Andere", "others": "Andere",
"overlay_color": "Overlay-Farbe", "overlay_color": "Overlay-Farbe",
"overview": "Überblick", "overview": "Überblick",
@@ -345,7 +339,6 @@
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus", "please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus", "please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan", "please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
"powered_by_formbricks": "Bereitgestellt von Formbricks",
"preview": "Vorschau", "preview": "Vorschau",
"preview_survey": "Umfragevorschau", "preview_survey": "Umfragevorschau",
"privacy": "Datenschutz", "privacy": "Datenschutz",
@@ -387,7 +380,6 @@
"select": "Auswählen", "select": "Auswählen",
"select_all": "Alles auswählen", "select_all": "Alles auswählen",
"select_filter": "Filter auswählen", "select_filter": "Filter auswählen",
"select_language": "Sprache auswählen",
"select_survey": "Umfrage auswählen", "select_survey": "Umfrage auswählen",
"select_teams": "Teams auswählen", "select_teams": "Teams auswählen",
"selected": "Ausgewählt", "selected": "Ausgewählt",
@@ -400,6 +392,7 @@
"show_response_count": "Antwortanzahl anzeigen", "show_response_count": "Antwortanzahl anzeigen",
"shown": "Angezeigt", "shown": "Angezeigt",
"size": "Größe", "size": "Größe",
"skip": "Überspringen",
"skipped": "Übersprungen", "skipped": "Übersprungen",
"skips": "Übersprungen", "skips": "Übersprungen",
"some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden", "some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden",
@@ -419,6 +412,7 @@
"survey_id": "Umfrage-ID", "survey_id": "Umfrage-ID",
"survey_languages": "Umfragesprachen", "survey_languages": "Umfragesprachen",
"survey_live": "Umfrage live", "survey_live": "Umfrage live",
"survey_not_found": "Umfrage nicht gefunden",
"survey_paused": "Umfrage pausiert.", "survey_paused": "Umfrage pausiert.",
"survey_type": "Umfragetyp", "survey_type": "Umfragetyp",
"surveys": "Umfragen", "surveys": "Umfragen",
@@ -433,6 +427,7 @@
"team_name": "Teamname", "team_name": "Teamname",
"team_role": "Team-Rolle", "team_role": "Team-Rolle",
"teams": "Teams", "teams": "Teams",
"teams_not_found": "Teams nicht gefunden",
"text": "Text", "text": "Text",
"time": "Zeit", "time": "Zeit",
"time_to_finish": "Zeit zum Fertigstellen", "time_to_finish": "Zeit zum Fertigstellen",
@@ -456,6 +451,7 @@
"url": "URL", "url": "URL",
"user": "Benutzer", "user": "Benutzer",
"user_id": "Benutzer-ID", "user_id": "Benutzer-ID",
"user_not_found": "Benutzer nicht gefunden",
"variable": "Variable", "variable": "Variable",
"variable_ids": "Variablen-IDs", "variable_ids": "Variablen-IDs",
"variables": "Variablen", "variables": "Variablen",
@@ -470,13 +466,15 @@
"website_survey": "Website-Umfrage", "website_survey": "Website-Umfrage",
"weeks": "Wochen", "weeks": "Wochen",
"welcome_card": "Willkommenskarte", "welcome_card": "Willkommenskarte",
"workspace": "Arbeitsbereich", "workflows": "Workflows",
"workspace_configuration": "Projektkonfiguration", "workspace_configuration": "Projektkonfiguration",
"workspace_created_successfully": "Projekt erfolgreich erstellt", "workspace_created_successfully": "Projekt erfolgreich erstellt",
"workspace_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.", "workspace_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"workspace_id": "Projekt-ID", "workspace_id": "Projekt-ID",
"workspace_name": "Projektname", "workspace_name": "Projektname",
"workspace_name_placeholder": "z. B. Formbricks", "workspace_name_placeholder": "z. B. Formbricks",
"workspace_not_found": "Projekt nicht gefunden",
"workspace_permission_not_found": "Projektberechtigung nicht gefunden",
"workspaces": "Projekte", "workspaces": "Projekte",
"years": "Jahre", "years": "Jahre",
"you": "Du", "you": "Du",
@@ -505,7 +503,7 @@
"forgot_password_email_change_password": "Passwort ändern", "forgot_password_email_change_password": "Passwort ändern",
"forgot_password_email_did_not_request": "Wenn Du sie nicht angefordert hast, ignoriere bitte diese E-Mail.", "forgot_password_email_did_not_request": "Wenn Du sie nicht angefordert hast, ignoriere bitte diese E-Mail.",
"forgot_password_email_heading": "Passwort ändern", "forgot_password_email_heading": "Passwort ändern",
"forgot_password_email_link_valid_for_24_hours": "Der Link ist {minutes} Minuten gültig.", "forgot_password_email_link_valid_for_24_hours": "Der Link ist 24 Stunden gültig.",
"forgot_password_email_subject": "Setz dein Formbricks-Passwort zurück", "forgot_password_email_subject": "Setz dein Formbricks-Passwort zurück",
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:", "forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
"hidden_field": "Verstecktes Feld", "hidden_field": "Verstecktes Feld",
@@ -661,6 +659,7 @@
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt", "attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.", "attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht", "contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
"contact_not_found": "Kein solcher Kontakt gefunden",
"contacts_table_refresh": "Kontakte aktualisieren", "contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert", "contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"create_attribute": "Attribut erstellen", "create_attribute": "Attribut erstellen",
@@ -851,16 +850,9 @@
"created_by_third_party": "Erstellt von einer dritten Partei", "created_by_third_party": "Erstellt von einer dritten Partei",
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.", "discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️", "empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
"endpoint_bad_gateway_error": "Ungültiges Gateway (502): Proxy-/Gateway-Fehler, Dienst nicht erreichbar",
"endpoint_gateway_timeout_error": "Gateway-Zeitüberschreitung (504): Gateway-Zeitüberschreitung, Dienst nicht erreichbar",
"endpoint_internal_server_error": "Interner Serverfehler (500): Der Dienst ist auf einen unerwarteten Fehler gestoßen",
"endpoint_method_not_allowed_error": "Methode nicht erlaubt (405): Der Endpoint existiert, akzeptiert aber keine POST-Anfragen",
"endpoint_not_found_error": "Nicht gefunden (404): Der Endpoint existiert nicht",
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!", "endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!", "endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
"endpoint_service_unavailable_error": "Dienst nicht verfügbar (503): Dienst ist vorübergehend nicht verfügbar",
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren", "learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
"no_triggers": "Keine Trigger",
"please_check_console": "Bitte überprüfe die Konsole für weitere Details", "please_check_console": "Bitte überprüfe die Konsole für weitere Details",
"please_enter_a_url": "Bitte gib eine URL ein", "please_enter_a_url": "Bitte gib eine URL ein",
"response_created": "Antwort erstellt", "response_created": "Antwort erstellt",
@@ -1079,25 +1071,6 @@
"enterprise_features": "Unternehmensfunktionen", "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.", "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.", "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_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_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", "license_status": "Lizenzstatus",
@@ -1129,13 +1102,6 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos." "unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos."
}, },
"general": { "general": {
"ai_data_analysis_enabled": "Datenanreicherung & Analyse (KI)",
"ai_data_analysis_enabled_description": "KI, um mehr aus deinen Daten herauszuholen, Dashboards, Diagramme, Berichte und mehr einzurichten. Greift auf deine Erfahrungsdaten zu.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
"ai_settings_updated_successfully": "KI-Einstellungen erfolgreich aktualisiert",
"ai_smart_tools_enabled": "Intelligente Funktionen (KI)",
"ai_smart_tools_enabled_description": "KI, um dir zu helfen, in kürzerer Zeit mehr zu erreichen. Greift niemals auf mit Formbricks gesammelte Daten zu. Wird nur verwendet, um z. B. Umfragen in andere Sprachen zu übersetzen.",
"bulk_invite_warning_description": "Bitte beachte, dass im Free-Plan alle Organisationsmitglieder automatisch die Rolle \"Owner\" zugewiesen bekommen, unabhängig von der im CSV-File angegebenen Rolle.", "bulk_invite_warning_description": "Bitte beachte, dass im Free-Plan alle Organisationsmitglieder automatisch die Rolle \"Owner\" zugewiesen bekommen, unabhängig von der im CSV-File angegebenen Rolle.",
"cannot_delete_only_organization": "Das ist deine einzige Organisation, sie kann nicht gelöscht werden. Erstelle zuerst eine neue Organisation.", "cannot_delete_only_organization": "Das ist deine einzige Organisation, sie kann nicht gelöscht werden. Erstelle zuerst eine neue Organisation.",
"cannot_leave_only_organization": "Du kannst diese Organisation nicht verlassen, da es deine einzige Organisation ist. Erstelle zuerst eine neue Organisation.", "cannot_leave_only_organization": "Du kannst diese Organisation nicht verlassen, da es deine einzige Organisation ist. Erstelle zuerst eine neue Organisation.",
@@ -1426,6 +1392,7 @@
"custom_hostname": "Benutzerdefinierter Hostname", "custom_hostname": "Benutzerdefinierter Hostname",
"customize_survey_logo": "Umfragelogo anpassen", "customize_survey_logo": "Umfragelogo anpassen",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.", "darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"date_format": "Datumsformat",
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.", "days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
"delete_anyways": "Trotzdem löschen", "delete_anyways": "Trotzdem löschen",
"delete_block": "Block löschen", "delete_block": "Block löschen",
@@ -1463,7 +1430,6 @@
"error_saving_changes": "Fehler beim Speichern der Änderungen", "error_saving_changes": "Fehler beim Speichern der Änderungen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).", "even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).",
"everyone": "Jeder", "everyone": "Jeder",
"expand_preview": "Vorschau erweitern",
"external_urls_paywall_tooltip": "Bitte upgrade auf einen kostenpflichtigen Tarif, um externe URLs anzupassen. So helfen wir, Phishing zu verhindern.", "external_urls_paywall_tooltip": "Bitte upgrade auf einen kostenpflichtigen Tarif, um externe URLs anzupassen. So helfen wir, Phishing zu verhindern.",
"fallback_missing": "Fehlender Fallback", "fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
@@ -1689,8 +1655,6 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Antwortlimit muss die Anzahl der erhaltenen Antworten ({responseCount}) überschreiten.", "response_limit_needs_to_exceed_number_of_received_responses": "Antwortlimit muss die Anzahl der erhaltenen Antworten ({responseCount}) überschreiten.",
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.", "response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
"response_options": "Antwortoptionen", "response_options": "Antwortoptionen",
"reverse_order_occasionally": "Reihenfolge gelegentlich umkehren",
"reverse_order_occasionally_except_last": "Reihenfolge gelegentlich umkehren, außer letzter",
"roundness": "Rundheit", "roundness": "Rundheit",
"roundness_description": "Steuert, wie abgerundet die Ecken sind.", "roundness_description": "Steuert, wie abgerundet die Ecken sind.",
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.", "row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
@@ -1719,7 +1683,6 @@
"show_survey_maximum_of": "Umfrage maximal anzeigen von", "show_survey_maximum_of": "Umfrage maximal anzeigen von",
"show_survey_to_users": "Umfrage % der Nutzer anzeigen", "show_survey_to_users": "Umfrage % der Nutzer anzeigen",
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer", "show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
"shrink_preview": "Vorschau verkleinern",
"simple": "Einfach", "simple": "Einfach",
"six_points": "6 Punkte", "six_points": "6 Punkte",
"smiley": "Smiley", "smiley": "Smiley",
@@ -1735,12 +1698,10 @@
"styling_set_to_theme_styles": "Styling auf Themenstile eingestellt", "styling_set_to_theme_styles": "Styling auf Themenstile eingestellt",
"subheading": "Zwischenüberschrift", "subheading": "Zwischenüberschrift",
"subtract": "Subtrahieren -", "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_heading": "Umfrage abgeschlossen",
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen", "survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage", "survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
"survey_placement": "Platzierung der Umfrage", "survey_placement": "Platzierung der Umfrage",
"survey_preview": "Umfragevorschau 👀",
"survey_styling": "Umfrage Styling", "survey_styling": "Umfrage Styling",
"survey_trigger": "Auslöser der Umfrage", "survey_trigger": "Auslöser der Umfrage",
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉", "switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
@@ -2619,8 +2580,8 @@
"csat_question_1_headline": "Wie wahrscheinlich ist es, dass Du dieses $[projectName] einem Freund oder Kollegen empfehlen würdest?", "csat_question_1_headline": "Wie wahrscheinlich ist es, dass Du dieses $[projectName] einem Freund oder Kollegen empfehlen würdest?",
"csat_question_1_lower_label": "Nicht wahrscheinlich", "csat_question_1_lower_label": "Nicht wahrscheinlich",
"csat_question_1_upper_label": "Sehr wahrscheinlich", "csat_question_1_upper_label": "Sehr wahrscheinlich",
"csat_question_2_choice_1": "Sehr zufrieden", "csat_question_2_choice_1": "Etwas zufrieden",
"csat_question_2_choice_2": "Etwas zufrieden", "csat_question_2_choice_2": "Sehr zufrieden",
"csat_question_2_choice_3": "Weder zufrieden noch unzufrieden", "csat_question_2_choice_3": "Weder zufrieden noch unzufrieden",
"csat_question_2_choice_4": "Etwas unzufrieden", "csat_question_2_choice_4": "Etwas unzufrieden",
"csat_question_2_choice_5": "Sehr unzufrieden", "csat_question_2_choice_5": "Sehr unzufrieden",
@@ -3091,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "Nein, danke!", "preview_survey_question_2_choice_2_label": "Nein, danke!",
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?", "preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.", "preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
"preview_survey_question_open_text_headline": "Möchten Sie noch etwas mitteilen?", "preview_survey_question_open_text_headline": "Möchtest Du noch etwas teilen?",
"preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...", "preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...",
"preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.", "preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.",
"preview_survey_welcome_card_headline": "Willkommen!", "preview_survey_welcome_card_headline": "Willkommen!",
@@ -3342,5 +3303,18 @@
"usability_question_9_headline": "Ich fühlte mich beim Benutzen des Systems sicher.", "usability_question_9_headline": "Ich fühlte mich beim Benutzen des Systems sicher.",
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.", "usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
"usability_score_name": "System Usability Score Survey (SUS)" "usability_score_name": "System Usability Score Survey (SUS)"
},
"workflows": {
"coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.",
"coming_soon_title": "Wir sind fast da!",
"follow_up_label": "Gibt es noch etwas, das du hinzufügen möchtest?",
"follow_up_placeholder": "Welche konkreten Aufgaben möchten Sie automatisieren? Gibt es Tools oder Integrationen, die Sie einbinden möchten?",
"generate_button": "Workflow generieren",
"heading": "Welchen Workflow möchtest du erstellen?",
"placeholder": "Beschreiben Sie den Workflow, den Sie erstellen möchten…",
"subheading": "Generiere deinen Workflow in Sekunden.",
"submit_button": "Details hinzufügen",
"thank_you_description": "Ihr Feedback hilft uns, die Workflows-Funktion so zu gestalten, wie Sie sie brauchen. Wir halten Sie über unseren Fortschritt auf dem Laufenden.",
"thank_you_title": "Danke für dein Feedback!"
} }
} }
+30 -56
View File
@@ -167,7 +167,6 @@
"connect": "Connect", "connect": "Connect",
"connect_formbricks": "Connect Formbricks", "connect_formbricks": "Connect Formbricks",
"connected": "Connected", "connected": "Connected",
"contact": "Contact",
"contacts": "Contacts", "contacts": "Contacts",
"continue": "Continue", "continue": "Continue",
"copied": "Copied", "copied": "Copied",
@@ -175,7 +174,6 @@
"copy": "Copy", "copy": "Copy",
"copy_code": "Copy code", "copy_code": "Copy code",
"copy_link": "Copy Link", "copy_link": "Copy Link",
"copy_to_environment": "Copy to {{environment}}",
"count_attributes": "{count, plural, one {{count} attribute} other {{count} attributes}}", "count_attributes": "{count, plural, one {{count} attribute} other {{count} attributes}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}", "count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} member} other {{count} members}}", "count_members": "{count, plural, one {{count} member} other {{count} members}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(copy {copyNumber})", "duplicate_copy_number": "(copy {copyNumber})",
"e_commerce": "E-Commerce", "e_commerce": "E-Commerce",
"edit": "Edit", "edit": "Edit",
"elements": "Elements",
"email": "Email", "email": "Email",
"ending_card": "Ending card", "ending_card": "Ending card",
"enter_url": "Enter URL", "enter_url": "Enter URL",
"enterprise_license": "Enterprise License", "enterprise_license": "Enterprise License",
"environment": "Environment", "environment": "Environment",
"environment_not_found": "Environment not found",
"environment_notice": "You are currently in the {environment} environment.", "environment_notice": "You are currently in the {environment} environment.",
"error": "Error", "error": "Error",
"error_component_description": "This resource does not exist or you do not have the necessary rights to access it.", "error_component_description": "This resource does not exist or you do not have the necessary rights to access it.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Inactive surveys", "inactive_surveys": "Inactive surveys",
"integration": "integration", "integration": "integration",
"integrations": "Integrations", "integrations": "Integrations",
"invalid_date_with_value": "Invalid date: {value}", "invalid_date": "Invalid date",
"invalid_file_name": "Invalid file name, please rename your file and try again", "invalid_file_name": "Invalid file name, please rename your file and try again",
"invalid_file_type": "Invalid file type", "invalid_file_type": "Invalid file type",
"invite": "Invite", "invite": "Invite",
"invite_them": "Invite them", "invite_them": "Invite them",
"javascript_required": "JavaScript Required",
"javascript_required_description": "Formbricks requires JavaScript to function properly. Please enable JavaScript in your browser settings to continue.",
"key": "Key", "key": "Key",
"label": "Label", "label": "Label",
"language": "Language", "language": "Language",
@@ -284,9 +280,7 @@
"marketing": "Marketing", "marketing": "Marketing",
"members": "Members", "members": "Members",
"members_and_teams": "Members & Teams", "members_and_teams": "Members & Teams",
"membership": "Membership",
"membership_not_found": "Membership not found", "membership_not_found": "Membership not found",
"meta": "Meta",
"metadata": "Metadata", "metadata": "Metadata",
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.", "mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Do not worry your surveys look great on every device and screen size!", "mobile_overlay_surveys_look_good": "Do not worry your surveys look great on every device and screen size!",
@@ -300,7 +294,6 @@
"new": "New", "new": "New",
"new_version_available": "Formbricks {version} is here. Upgrade now!", "new_version_available": "Formbricks {version} is here. Upgrade now!",
"next": "Next", "next": "Next",
"no_actions_found": "No actions found",
"no_background_image_found": "No background image found.", "no_background_image_found": "No background image found.",
"no_code": "No code", "no_code": "No code",
"no_files_uploaded": "No files were uploaded", "no_files_uploaded": "No files were uploaded",
@@ -326,9 +319,10 @@
"or": "or", "or": "or",
"organization": "Organization", "organization": "Organization",
"organization_id": "Organization ID", "organization_id": "Organization ID",
"organization_not_found": "Organization not found",
"organization_settings": "Organization settings", "organization_settings": "Organization settings",
"organization_teams_not_found": "Organization teams not found",
"other": "Other", "other": "Other",
"other_filters": "Other Filters",
"others": "Others", "others": "Others",
"overlay_color": "Overlay color", "overlay_color": "Overlay color",
"overview": "Overview", "overview": "Overview",
@@ -345,7 +339,6 @@
"please_select_at_least_one_survey": "Please select at least one survey", "please_select_at_least_one_survey": "Please select at least one survey",
"please_select_at_least_one_trigger": "Please select at least one trigger", "please_select_at_least_one_trigger": "Please select at least one trigger",
"please_upgrade_your_plan": "Please upgrade your plan", "please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview", "preview": "Preview",
"preview_survey": "Preview Survey", "preview_survey": "Preview Survey",
"privacy": "Privacy Policy", "privacy": "Privacy Policy",
@@ -387,7 +380,6 @@
"select": "Select", "select": "Select",
"select_all": "Select all", "select_all": "Select all",
"select_filter": "Select filter", "select_filter": "Select filter",
"select_language": "Select Language",
"select_survey": "Select Survey", "select_survey": "Select Survey",
"select_teams": "Select teams", "select_teams": "Select teams",
"selected": "Selected", "selected": "Selected",
@@ -400,6 +392,7 @@
"show_response_count": "Show response count", "show_response_count": "Show response count",
"shown": "Shown", "shown": "Shown",
"size": "Size", "size": "Size",
"skip": "Skip",
"skipped": "Skipped", "skipped": "Skipped",
"skips": "Skips", "skips": "Skips",
"some_files_failed_to_upload": "Some files failed to upload", "some_files_failed_to_upload": "Some files failed to upload",
@@ -419,6 +412,7 @@
"survey_id": "Survey ID", "survey_id": "Survey ID",
"survey_languages": "Survey Languages", "survey_languages": "Survey Languages",
"survey_live": "Survey live", "survey_live": "Survey live",
"survey_not_found": "Survey not found",
"survey_paused": "Survey paused.", "survey_paused": "Survey paused.",
"survey_type": "Survey Type", "survey_type": "Survey Type",
"surveys": "Surveys", "surveys": "Surveys",
@@ -433,6 +427,7 @@
"team_name": "Team name", "team_name": "Team name",
"team_role": "Team role", "team_role": "Team role",
"teams": "Teams", "teams": "Teams",
"teams_not_found": "Teams not found",
"text": "Text", "text": "Text",
"time": "Time", "time": "Time",
"time_to_finish": "Time to finish", "time_to_finish": "Time to finish",
@@ -456,6 +451,7 @@
"url": "URL", "url": "URL",
"user": "User", "user": "User",
"user_id": "User ID", "user_id": "User ID",
"user_not_found": "User not found",
"variable": "Variable", "variable": "Variable",
"variable_ids": "Variable IDs", "variable_ids": "Variable IDs",
"variables": "Variables", "variables": "Variables",
@@ -470,13 +466,15 @@
"website_survey": "Website Survey", "website_survey": "Website Survey",
"weeks": "weeks", "weeks": "weeks",
"welcome_card": "Welcome card", "welcome_card": "Welcome card",
"workspace": "Workspace", "workflows": "Workflows",
"workspace_configuration": "Workspace Configuration", "workspace_configuration": "Workspace Configuration",
"workspace_created_successfully": "Workspace created successfully", "workspace_created_successfully": "Workspace created successfully",
"workspace_creation_description": "Organize surveys in workspaces for better access control.", "workspace_creation_description": "Organize surveys in workspaces for better access control.",
"workspace_id": "Workspace ID", "workspace_id": "Workspace ID",
"workspace_name": "Workspace Name", "workspace_name": "Workspace Name",
"workspace_name_placeholder": "e.g. Formbricks", "workspace_name_placeholder": "e.g. Formbricks",
"workspace_not_found": "Workspace not found",
"workspace_permission_not_found": "Workspace permission not found",
"workspaces": "Workspaces", "workspaces": "Workspaces",
"years": "years", "years": "years",
"you": "You", "you": "You",
@@ -505,7 +503,7 @@
"forgot_password_email_change_password": "Change password", "forgot_password_email_change_password": "Change password",
"forgot_password_email_did_not_request": "If you did not request this, please ignore this email.", "forgot_password_email_did_not_request": "If you did not request this, please ignore this email.",
"forgot_password_email_heading": "Change password", "forgot_password_email_heading": "Change password",
"forgot_password_email_link_valid_for_24_hours": "The link is valid for {minutes} minutes.", "forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"forgot_password_email_subject": "Reset your Formbricks password", "forgot_password_email_subject": "Reset your Formbricks password",
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:", "forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
"hidden_field": "Hidden field", "hidden_field": "Hidden field",
@@ -661,6 +659,7 @@
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”", "attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.", "attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
"contact_deleted_successfully": "Contact deleted successfully", "contact_deleted_successfully": "Contact deleted successfully",
"contact_not_found": "No such contact found",
"contacts_table_refresh": "Refresh contacts", "contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully", "contacts_table_refresh_success": "Contacts refreshed successfully",
"create_attribute": "Create attribute", "create_attribute": "Create attribute",
@@ -851,16 +850,9 @@
"created_by_third_party": "Created by a Third Party", "created_by_third_party": "Created by a Third Party",
"discord_webhook_not_supported": "Discord webhooks are currently not supported.", "discord_webhook_not_supported": "Discord webhooks are currently not supported.",
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️", "empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
"endpoint_bad_gateway_error": "Bad Gateway (502): Proxy/gateway error, service not reachable",
"endpoint_gateway_timeout_error": "Gateway Timeout (504): Gateway timeout, service not reachable",
"endpoint_internal_server_error": "Internal Server Error (500): The service encountered an unexpected error",
"endpoint_method_not_allowed_error": "Method Not Allowed (405): The endpoint exists, but doesn't accept POST requests",
"endpoint_not_found_error": "Not Found (404): The endpoint doesn't exist",
"endpoint_pinged": "Yay! We are able to ping the webhook!", "endpoint_pinged": "Yay! We are able to ping the webhook!",
"endpoint_pinged_error": "Unable to ping the webhook!", "endpoint_pinged_error": "Unable to ping the webhook!",
"endpoint_service_unavailable_error": "Service Unavailable (503): Service is temporarily down",
"learn_to_verify": "Learn how to verify webhook signatures", "learn_to_verify": "Learn how to verify webhook signatures",
"no_triggers": "No Triggers",
"please_check_console": "Please check the console for more details", "please_check_console": "Please check the console for more details",
"please_enter_a_url": "Please enter a URL", "please_enter_a_url": "Please enter a URL",
"response_created": "Response Created", "response_created": "Response Created",
@@ -1079,25 +1071,6 @@
"enterprise_features": "Enterprise Features", "enterprise_features": "Enterprise Features",
"get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all 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.", "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_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_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", "license_status": "License Status",
@@ -1129,13 +1102,6 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Unlock the full power of Formbricks. Free for 30 days." "unlock_the_full_power_of_formbricks_free_for_30_days": "Unlock the full power of Formbricks. Free for 30 days."
}, },
"general": { "general": {
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
"ai_data_analysis_enabled_description": "AI to get more out of your data, setup dashboards, charts, reports and more. Touches your experience data.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Manage AI-powered features for this organization.",
"ai_settings_updated_successfully": "AI settings updated successfully",
"ai_smart_tools_enabled": "Smart functionality (AI)",
"ai_smart_tools_enabled_description": "AI to help you achieve more in less time. Never touches data collected with Formbricks. Only used to e.g. translate surveys to other languages.",
"bulk_invite_warning_description": "On the free plan, all organization members are always assigned the “Owner” role.", "bulk_invite_warning_description": "On the free plan, all organization members are always assigned the “Owner” role.",
"cannot_delete_only_organization": "This is your only organization, it cannot be deleted. Create a new organization first.", "cannot_delete_only_organization": "This is your only organization, it cannot be deleted. Create a new organization first.",
"cannot_leave_only_organization": "You cannot leave this organization as it is your only organization. Create a new organization first.", "cannot_leave_only_organization": "You cannot leave this organization as it is your only organization. Create a new organization first.",
@@ -1426,6 +1392,7 @@
"custom_hostname": "Custom hostname", "custom_hostname": "Custom hostname",
"customize_survey_logo": "Customize the survey logo", "customize_survey_logo": "Customize the survey logo",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.", "darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.", "days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"delete_anyways": "Delete anyways", "delete_anyways": "Delete anyways",
"delete_block": "Delete block", "delete_block": "Delete block",
@@ -1463,7 +1430,6 @@
"error_saving_changes": "Error saving changes", "error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).", "even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
"everyone": "Everyone", "everyone": "Everyone",
"expand_preview": "Expand Preview",
"external_urls_paywall_tooltip": "Please upgrade to a paid plan to customize external URLs. This helps us prevent phishing.", "external_urls_paywall_tooltip": "Please upgrade to a paid plan to customize external URLs. This helps us prevent phishing.",
"fallback_missing": "Fallback missing", "fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1689,8 +1655,6 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Response limit needs to exceed number of received responses ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "Response limit needs to exceed number of received responses ({responseCount}).",
"response_limits_redirections_and_more": "Response limits, redirections and more.", "response_limits_redirections_and_more": "Response limits, redirections and more.",
"response_options": "Response Options", "response_options": "Response Options",
"reverse_order_occasionally": "Reverse order occasionally",
"reverse_order_occasionally_except_last": "Reverse order occasionally except last",
"roundness": "Roundness", "roundness": "Roundness",
"roundness_description": "Controls how rounded corners are.", "roundness_description": "Controls how rounded corners are.",
"row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.", "row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1719,7 +1683,6 @@
"show_survey_maximum_of": "Show survey maximum of", "show_survey_maximum_of": "Show survey maximum of",
"show_survey_to_users": "Show survey to % of users", "show_survey_to_users": "Show survey to % of users",
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users", "show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
"shrink_preview": "Shrink Preview",
"simple": "Simple", "simple": "Simple",
"six_points": "6 points", "six_points": "6 points",
"smiley": "Smiley", "smiley": "Smiley",
@@ -1735,12 +1698,10 @@
"styling_set_to_theme_styles": "Styling set to theme styles", "styling_set_to_theme_styles": "Styling set to theme styles",
"subheading": "Subheading", "subheading": "Subheading",
"subtract": "Subtract -", "subtract": "Subtract -",
"survey_closed_message_heading_required": "Add a heading to the custom survey closed message.",
"survey_completed_heading": "Survey Completed", "survey_completed_heading": "Survey Completed",
"survey_completed_subheading": "This free & open-source survey has been closed", "survey_completed_subheading": "This free & open-source survey has been closed",
"survey_display_settings": "Survey Display Settings", "survey_display_settings": "Survey Display Settings",
"survey_placement": "Survey Placement", "survey_placement": "Survey Placement",
"survey_preview": "Survey Preview 👀",
"survey_styling": "Survey styling", "survey_styling": "Survey styling",
"survey_trigger": "Survey Trigger", "survey_trigger": "Survey Trigger",
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉", "switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
@@ -2619,8 +2580,8 @@
"csat_question_1_headline": "How likely is it that you would recommend this $[projectName] to a friend or colleague?", "csat_question_1_headline": "How likely is it that you would recommend this $[projectName] to a friend or colleague?",
"csat_question_1_lower_label": "Not likely", "csat_question_1_lower_label": "Not likely",
"csat_question_1_upper_label": "Very likely", "csat_question_1_upper_label": "Very likely",
"csat_question_2_choice_1": "Very satisfied", "csat_question_2_choice_1": "Somewhat satisfied",
"csat_question_2_choice_2": "Somewhat satisfied", "csat_question_2_choice_2": "Very satisfied",
"csat_question_2_choice_3": "Neither satisfied nor dissatisfied", "csat_question_2_choice_3": "Neither satisfied nor dissatisfied",
"csat_question_2_choice_4": "Somewhat dissatisfied", "csat_question_2_choice_4": "Somewhat dissatisfied",
"csat_question_2_choice_5": "Very dissatisfied", "csat_question_2_choice_5": "Very dissatisfied",
@@ -3091,7 +3052,7 @@
"preview_survey_question_2_choice_2_label": "No, thank you!", "preview_survey_question_2_choice_2_label": "No, thank you!",
"preview_survey_question_2_headline": "Want to stay in the loop?", "preview_survey_question_2_headline": "Want to stay in the loop?",
"preview_survey_question_2_subheader": "This is an example description.", "preview_survey_question_2_subheader": "This is an example description.",
"preview_survey_question_open_text_headline": "Anything else you would like to share?", "preview_survey_question_open_text_headline": "Anything else you'd like to share?",
"preview_survey_question_open_text_placeholder": "Type your answer here…", "preview_survey_question_open_text_placeholder": "Type your answer here…",
"preview_survey_question_open_text_subheader": "Your feedback helps us improve.", "preview_survey_question_open_text_subheader": "Your feedback helps us improve.",
"preview_survey_welcome_card_headline": "Welcome!", "preview_survey_welcome_card_headline": "Welcome!",
@@ -3342,5 +3303,18 @@
"usability_question_9_headline": "I felt confident while using the system.", "usability_question_9_headline": "I felt confident while using the system.",
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.", "usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)" "usability_score_name": "System Usability Score (SUS)"
},
"workflows": {
"coming_soon_description": "Thank you for sharing your workflow idea with us! We are currently designing this feature and your feedback will help us build exactly what you need.",
"coming_soon_title": "We are almost there!",
"follow_up_label": "Is there anything else you'd like to add?",
"follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you would want included?",
"generate_button": "Generate workflow",
"heading": "What workflow do you want to create?",
"placeholder": "Describe the workflow you want to generate…",
"subheading": "Generate your workflow in seconds.",
"submit_button": "Add details",
"thank_you_description": "Your input helps us build the Workflows feature you actually need. We will keep you posted on our progress.",
"thank_you_title": "Thank you for your feedback!"
} }
} }
+29 -55
View File
@@ -167,7 +167,6 @@
"connect": "Conectar", "connect": "Conectar",
"connect_formbricks": "Conectar Formbricks", "connect_formbricks": "Conectar Formbricks",
"connected": "Conectado", "connected": "Conectado",
"contact": "Contacto",
"contacts": "Contactos", "contacts": "Contactos",
"continue": "Continuar", "continue": "Continuar",
"copied": "Copiado", "copied": "Copiado",
@@ -175,7 +174,6 @@
"copy": "Copiar", "copy": "Copiar",
"copy_code": "Copiar código", "copy_code": "Copiar código",
"copy_link": "Copiar enlace", "copy_link": "Copiar enlace",
"copy_to_environment": "Copiar a {{environment}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}", "count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}", "count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
"count_members": "{count, plural, one {{count} miembro} other {{count} miembros}}", "count_members": "{count, plural, one {{count} miembro} other {{count} miembros}}",
@@ -215,12 +213,12 @@
"duplicate_copy_number": "(copia {copyNumber})", "duplicate_copy_number": "(copia {copyNumber})",
"e_commerce": "Comercio electrónico", "e_commerce": "Comercio electrónico",
"edit": "Editar", "edit": "Editar",
"elements": "Elementos",
"email": "Email", "email": "Email",
"ending_card": "Tarjeta final", "ending_card": "Tarjeta final",
"enter_url": "Introducir URL", "enter_url": "Introducir URL",
"enterprise_license": "Licencia empresarial", "enterprise_license": "Licencia empresarial",
"environment": "Entorno", "environment": "Entorno",
"environment_not_found": "Entorno no encontrado",
"environment_notice": "Actualmente estás en el entorno {environment}.", "environment_notice": "Actualmente estás en el entorno {environment}.",
"error": "Error", "error": "Error",
"error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.", "error_component_description": "Este recurso no existe o no tienes los derechos necesarios para acceder a él.",
@@ -257,13 +255,11 @@
"inactive_surveys": "Encuestas inactivas", "inactive_surveys": "Encuestas inactivas",
"integration": "integración", "integration": "integración",
"integrations": "Integraciones", "integrations": "Integraciones",
"invalid_date_with_value": "Fecha no válida: {value}", "invalid_date": "Fecha no válida",
"invalid_file_name": "Nombre de archivo no válido, por favor renombre su archivo e inténtelo de nuevo", "invalid_file_name": "Nombre de archivo no válido, por favor renombre su archivo e inténtelo de nuevo",
"invalid_file_type": "Tipo de archivo no válido", "invalid_file_type": "Tipo de archivo no válido",
"invite": "Invitar", "invite": "Invitar",
"invite_them": "Invítales", "invite_them": "Invítales",
"javascript_required": "Se requiere JavaScript",
"javascript_required_description": "Formbricks requiere JavaScript para funcionar correctamente. Por favor, activa JavaScript en la configuración de tu navegador para continuar.",
"key": "Clave", "key": "Clave",
"label": "Etiqueta", "label": "Etiqueta",
"language": "Idioma", "language": "Idioma",
@@ -284,9 +280,7 @@
"marketing": "Marketing", "marketing": "Marketing",
"members": "Miembros", "members": "Miembros",
"members_and_teams": "Miembros y equipos", "members_and_teams": "Miembros y equipos",
"membership": "Membresía",
"membership_not_found": "Membresía no encontrada", "membership_not_found": "Membresía no encontrada",
"meta": "Meta",
"metadata": "Metadatos", "metadata": "Metadatos",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.", "mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
"mobile_overlay_surveys_look_good": "No te preocupes ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!", "mobile_overlay_surveys_look_good": "No te preocupes ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
@@ -300,7 +294,6 @@
"new": "Nuevo", "new": "Nuevo",
"new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!", "new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!",
"next": "Siguiente", "next": "Siguiente",
"no_actions_found": "No se encontraron acciones",
"no_background_image_found": "No se encontró imagen de fondo.", "no_background_image_found": "No se encontró imagen de fondo.",
"no_code": "Sin código", "no_code": "Sin código",
"no_files_uploaded": "No se subieron archivos", "no_files_uploaded": "No se subieron archivos",
@@ -326,9 +319,10 @@
"or": "o", "or": "o",
"organization": "Organización", "organization": "Organización",
"organization_id": "ID de organización", "organization_id": "ID de organización",
"organization_not_found": "Organización no encontrada",
"organization_settings": "Ajustes de la organización", "organization_settings": "Ajustes de la organización",
"organization_teams_not_found": "Equipos de la organización no encontrados",
"other": "Otro", "other": "Otro",
"other_filters": "Otros Filtros",
"others": "Otros", "others": "Otros",
"overlay_color": "Color de superposición", "overlay_color": "Color de superposición",
"overview": "Resumen", "overview": "Resumen",
@@ -345,7 +339,6 @@
"please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta", "please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta",
"please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador", "please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador",
"please_upgrade_your_plan": "Por favor, actualiza tu plan", "please_upgrade_your_plan": "Por favor, actualiza tu plan",
"powered_by_formbricks": "Desarrollado por Formbricks",
"preview": "Vista previa", "preview": "Vista previa",
"preview_survey": "Vista previa de la encuesta", "preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad", "privacy": "Política de privacidad",
@@ -387,7 +380,6 @@
"select": "Seleccionar", "select": "Seleccionar",
"select_all": "Seleccionar todo", "select_all": "Seleccionar todo",
"select_filter": "Seleccionar filtro", "select_filter": "Seleccionar filtro",
"select_language": "Seleccionar idioma",
"select_survey": "Seleccionar encuesta", "select_survey": "Seleccionar encuesta",
"select_teams": "Seleccionar equipos", "select_teams": "Seleccionar equipos",
"selected": "Seleccionado", "selected": "Seleccionado",
@@ -400,6 +392,7 @@
"show_response_count": "Mostrar recuento de respuestas", "show_response_count": "Mostrar recuento de respuestas",
"shown": "Mostrado", "shown": "Mostrado",
"size": "Tamaño", "size": "Tamaño",
"skip": "Omitir",
"skipped": "Omitido", "skipped": "Omitido",
"skips": "Omisiones", "skips": "Omisiones",
"some_files_failed_to_upload": "Algunos archivos no se han podido subir", "some_files_failed_to_upload": "Algunos archivos no se han podido subir",
@@ -419,6 +412,7 @@
"survey_id": "ID de encuesta", "survey_id": "ID de encuesta",
"survey_languages": "Idiomas de la encuesta", "survey_languages": "Idiomas de la encuesta",
"survey_live": "Encuesta activa", "survey_live": "Encuesta activa",
"survey_not_found": "Encuesta no encontrada",
"survey_paused": "Encuesta pausada.", "survey_paused": "Encuesta pausada.",
"survey_type": "Tipo de encuesta", "survey_type": "Tipo de encuesta",
"surveys": "Encuestas", "surveys": "Encuestas",
@@ -433,6 +427,7 @@
"team_name": "Nombre del equipo", "team_name": "Nombre del equipo",
"team_role": "Rol del equipo", "team_role": "Rol del equipo",
"teams": "Equipos", "teams": "Equipos",
"teams_not_found": "Equipos no encontrados",
"text": "Texto", "text": "Texto",
"time": "Hora", "time": "Hora",
"time_to_finish": "Tiempo para finalizar", "time_to_finish": "Tiempo para finalizar",
@@ -456,6 +451,7 @@
"url": "URL", "url": "URL",
"user": "Usuario", "user": "Usuario",
"user_id": "ID de usuario", "user_id": "ID de usuario",
"user_not_found": "Usuario no encontrado",
"variable": "Variable", "variable": "Variable",
"variable_ids": "IDs de variables", "variable_ids": "IDs de variables",
"variables": "Variables", "variables": "Variables",
@@ -470,13 +466,15 @@
"website_survey": "Encuesta de sitio web", "website_survey": "Encuesta de sitio web",
"weeks": "semanas", "weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida", "welcome_card": "Tarjeta de bienvenida",
"workspace": "Espacio de trabajo", "workflows": "Flujos de trabajo",
"workspace_configuration": "Configuración del proyecto", "workspace_configuration": "Configuración del proyecto",
"workspace_created_successfully": "Proyecto creado correctamente", "workspace_created_successfully": "Proyecto creado correctamente",
"workspace_creation_description": "Organiza las encuestas en proyectos para un mejor control de acceso.", "workspace_creation_description": "Organiza las encuestas en proyectos para un mejor control de acceso.",
"workspace_id": "ID del proyecto", "workspace_id": "ID del proyecto",
"workspace_name": "Nombre del proyecto", "workspace_name": "Nombre del proyecto",
"workspace_name_placeholder": "p. ej. Formbricks", "workspace_name_placeholder": "p. ej. Formbricks",
"workspace_not_found": "Proyecto no encontrado",
"workspace_permission_not_found": "Permiso del proyecto no encontrado",
"workspaces": "Proyectos", "workspaces": "Proyectos",
"years": "años", "years": "años",
"you": "Tú", "you": "Tú",
@@ -505,7 +503,7 @@
"forgot_password_email_change_password": "Cambiar contraseña", "forgot_password_email_change_password": "Cambiar contraseña",
"forgot_password_email_did_not_request": "Si no has solicitado esto, por favor ignora este correo electrónico.", "forgot_password_email_did_not_request": "Si no has solicitado esto, por favor ignora este correo electrónico.",
"forgot_password_email_heading": "Cambiar contraseña", "forgot_password_email_heading": "Cambiar contraseña",
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante {minutes} minutos.", "forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante 24 horas.",
"forgot_password_email_subject": "Restablece tu contraseña de Formbricks", "forgot_password_email_subject": "Restablece tu contraseña de Formbricks",
"forgot_password_email_text": "Has solicitado un enlace para cambiar tu contraseña. Puedes hacerlo haciendo clic en el enlace a continuación:", "forgot_password_email_text": "Has solicitado un enlace para cambiar tu contraseña. Puedes hacerlo haciendo clic en el enlace a continuación:",
"hidden_field": "Campo oculto", "hidden_field": "Campo oculto",
@@ -661,6 +659,7 @@
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”", "attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este entorno y no se actualizó.", "attributes_msg_userid_already_exists": "El ID de usuario ya existe para este entorno y no se actualizó.",
"contact_deleted_successfully": "Contacto eliminado correctamente", "contact_deleted_successfully": "Contacto eliminado correctamente",
"contact_not_found": "No se ha encontrado dicho contacto",
"contacts_table_refresh": "Actualizar contactos", "contacts_table_refresh": "Actualizar contactos",
"contacts_table_refresh_success": "Contactos actualizados correctamente", "contacts_table_refresh_success": "Contactos actualizados correctamente",
"create_attribute": "Crear atributo", "create_attribute": "Crear atributo",
@@ -851,16 +850,9 @@
"created_by_third_party": "Creado por un tercero", "created_by_third_party": "Creado por un tercero",
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.", "discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️", "empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
"endpoint_bad_gateway_error": "Puerta de enlace incorrecta (502): Error de proxy o puerta de enlace, servicio no accesible",
"endpoint_gateway_timeout_error": "Tiempo de espera de la puerta de enlace agotado (504): Tiempo de espera de la puerta de enlace agotado, servicio no accesible",
"endpoint_internal_server_error": "Error interno del servidor (500): El servicio encontró un error inesperado",
"endpoint_method_not_allowed_error": "Método no permitido (405): El endpoint existe, pero no acepta solicitudes POST",
"endpoint_not_found_error": "No encontrado (404): El endpoint no existe",
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!", "endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!", "endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
"endpoint_service_unavailable_error": "Servicio no disponible (503): El servicio está temporalmente caído",
"learn_to_verify": "Aprende a verificar las firmas de webhook", "learn_to_verify": "Aprende a verificar las firmas de webhook",
"no_triggers": "Sin activadores",
"please_check_console": "Por favor, consulta la consola para más detalles", "please_check_console": "Por favor, consulta la consola para más detalles",
"please_enter_a_url": "Por favor, introduce una URL", "please_enter_a_url": "Por favor, introduce una URL",
"response_created": "Respuesta creada", "response_created": "Respuesta creada",
@@ -1079,25 +1071,6 @@
"enterprise_features": "Características empresariales", "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.", "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.", "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_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_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", "license_status": "Estado de la licencia",
@@ -1129,13 +1102,6 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks. Gratis durante 30 días." "unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks. Gratis durante 30 días."
}, },
"general": { "general": {
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
"ai_data_analysis_enabled_description": "IA para sacar más partido a tus datos, configurar paneles, gráficos, informes y más. Accede a los datos de experiencia.",
"ai_enabled": "IA de Formbricks",
"ai_enabled_description": "Gestiona las funciones impulsadas por IA para esta organización.",
"ai_settings_updated_successfully": "Configuración de IA actualizada correctamente",
"ai_smart_tools_enabled": "Funcionalidad inteligente (IA)",
"ai_smart_tools_enabled_description": "IA para ayudarte a conseguir más en menos tiempo. Nunca accede a los datos recopilados con Formbricks. Solo se usa para, por ejemplo, traducir encuestas a otros idiomas.",
"bulk_invite_warning_description": "En el plan gratuito, a todos los miembros de la organización se les asigna siempre el rol de \"Propietario\".", "bulk_invite_warning_description": "En el plan gratuito, a todos los miembros de la organización se les asigna siempre el rol de \"Propietario\".",
"cannot_delete_only_organization": "Esta es tu única organización, no se puede eliminar. Crea una nueva organización primero.", "cannot_delete_only_organization": "Esta es tu única organización, no se puede eliminar. Crea una nueva organización primero.",
"cannot_leave_only_organization": "No puedes abandonar esta organización ya que es tu única organización. Crea una nueva organización primero.", "cannot_leave_only_organization": "No puedes abandonar esta organización ya que es tu única organización. Crea una nueva organización primero.",
@@ -1426,6 +1392,7 @@
"custom_hostname": "Nombre de host personalizado", "custom_hostname": "Nombre de host personalizado",
"customize_survey_logo": "Personalizar el logotipo de la encuesta", "customize_survey_logo": "Personalizar el logotipo de la encuesta",
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.", "darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
"date_format": "Formato de fecha",
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.", "days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
"delete_anyways": "Eliminar de todos modos", "delete_anyways": "Eliminar de todos modos",
"delete_block": "Eliminar bloque", "delete_block": "Eliminar bloque",
@@ -1463,7 +1430,6 @@
"error_saving_changes": "Error al guardar los cambios", "error_saving_changes": "Error al guardar los cambios",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).",
"everyone": "Todos", "everyone": "Todos",
"expand_preview": "Expandir vista previa",
"external_urls_paywall_tooltip": "Por favor, actualiza a un plan de pago para personalizar URLs externas. Esto nos ayuda a prevenir el phishing.", "external_urls_paywall_tooltip": "Por favor, actualiza a un plan de pago para personalizar URLs externas. Esto nos ayuda a prevenir el phishing.",
"fallback_missing": "Falta respaldo", "fallback_missing": "Falta respaldo",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.",
@@ -1689,8 +1655,6 @@
"response_limit_needs_to_exceed_number_of_received_responses": "El límite de respuestas debe superar el número de respuestas recibidas ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "El límite de respuestas debe superar el número de respuestas recibidas ({responseCount}).",
"response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.", "response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.",
"response_options": "Opciones de respuesta", "response_options": "Opciones de respuesta",
"reverse_order_occasionally": "Invertir orden ocasionalmente",
"reverse_order_occasionally_except_last": "Invertir orden ocasionalmente excepto el último",
"roundness": "Redondez", "roundness": "Redondez",
"roundness_description": "Controla qué tan redondeadas están las esquinas.", "roundness_description": "Controla qué tan redondeadas están las esquinas.",
"row_used_in_logic_error": "Esta fila se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.", "row_used_in_logic_error": "Esta fila se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
@@ -1719,7 +1683,6 @@
"show_survey_maximum_of": "Mostrar encuesta un máximo de", "show_survey_maximum_of": "Mostrar encuesta un máximo de",
"show_survey_to_users": "Mostrar encuesta al % de usuarios", "show_survey_to_users": "Mostrar encuesta al % de usuarios",
"show_to_x_percentage_of_targeted_users": "Mostrar al {percentage} % de usuarios objetivo", "show_to_x_percentage_of_targeted_users": "Mostrar al {percentage} % de usuarios objetivo",
"shrink_preview": "Contraer vista previa",
"simple": "Simple", "simple": "Simple",
"six_points": "6 puntos", "six_points": "6 puntos",
"smiley": "Emoticono", "smiley": "Emoticono",
@@ -1735,12 +1698,10 @@
"styling_set_to_theme_styles": "Estilo configurado según los estilos del tema", "styling_set_to_theme_styles": "Estilo configurado según los estilos del tema",
"subheading": "Subtítulo", "subheading": "Subtítulo",
"subtract": "Restar -", "subtract": "Restar -",
"survey_closed_message_heading_required": "Añade un encabezado al mensaje personalizado de encuesta cerrada.",
"survey_completed_heading": "Encuesta completada", "survey_completed_heading": "Encuesta completada",
"survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada", "survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada",
"survey_display_settings": "Ajustes de visualización de la encuesta", "survey_display_settings": "Ajustes de visualización de la encuesta",
"survey_placement": "Ubicación de la encuesta", "survey_placement": "Ubicación de la encuesta",
"survey_preview": "Vista previa de la encuesta 👀",
"survey_styling": "Estilo del formulario", "survey_styling": "Estilo del formulario",
"survey_trigger": "Activador de la encuesta", "survey_trigger": "Activador de la encuesta",
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉", "switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
@@ -2619,8 +2580,8 @@
"csat_question_1_headline": "¿Qué probabilidad hay de que recomiendes este $[projectName] a un amigo o colega?", "csat_question_1_headline": "¿Qué probabilidad hay de que recomiendes este $[projectName] a un amigo o colega?",
"csat_question_1_lower_label": "Poco probable", "csat_question_1_lower_label": "Poco probable",
"csat_question_1_upper_label": "Muy probable", "csat_question_1_upper_label": "Muy probable",
"csat_question_2_choice_1": "Muy satisfecho", "csat_question_2_choice_1": "Algo satisfecho",
"csat_question_2_choice_2": "Algo satisfecho", "csat_question_2_choice_2": "Muy satisfecho",
"csat_question_2_choice_3": "Ni satisfecho ni insatisfecho", "csat_question_2_choice_3": "Ni satisfecho ni insatisfecho",
"csat_question_2_choice_4": "Algo insatisfecho", "csat_question_2_choice_4": "Algo insatisfecho",
"csat_question_2_choice_5": "Muy insatisfecho", "csat_question_2_choice_5": "Muy insatisfecho",
@@ -3342,5 +3303,18 @@
"usability_question_9_headline": "Me sentí seguro mientras usaba el sistema.", "usability_question_9_headline": "Me sentí seguro mientras usaba el sistema.",
"usability_rating_description": "Mide la usabilidad percibida pidiendo a los usuarios que valoren su experiencia con tu producto mediante una encuesta estandarizada de 10 preguntas.", "usability_rating_description": "Mide la usabilidad percibida pidiendo a los usuarios que valoren su experiencia con tu producto mediante una encuesta estandarizada de 10 preguntas.",
"usability_score_name": "Puntuación de usabilidad del sistema (SUS)" "usability_score_name": "Puntuación de usabilidad del sistema (SUS)"
},
"workflows": {
"coming_soon_description": "¡Gracias por compartir tu idea de flujo de trabajo con nosotros! Actualmente estamos diseñando esta funcionalidad y tus comentarios nos ayudarán a construir exactamente lo que necesitas.",
"coming_soon_title": "¡Ya casi estamos!",
"follow_up_label": "¿Hay algo más que te gustaría añadir?",
"follow_up_placeholder": "¿Qué tareas específicas te gustaría automatizar? ¿Alguna herramienta o integración que quieras incluir?",
"generate_button": "Generar flujo de trabajo",
"heading": "¿Qué flujo de trabajo quieres crear?",
"placeholder": "Describe el flujo de trabajo que quieres generar…",
"subheading": "Genera tu flujo de trabajo en segundos.",
"submit_button": "Añadir detalles",
"thank_you_description": "Tu aportación nos ayuda a construir la funcionalidad de Flujos de trabajo que realmente necesitas. Te mantendremos informado sobre nuestro progreso.",
"thank_you_title": "¡Gracias por tus comentarios!"
} }
} }

Some files were not shown because too many files have changed in this diff Show More