Compare commits

..

4 Commits

Author SHA1 Message Date
Piyush Jain
769ed48a86 add observability config roles 2025-03-19 13:12:38 +05:30
Piyush Jain
d14262f804 add observability config roles 2025-03-19 13:11:44 +05:30
Piyush Jain
864ad8ac45 remove dead code 2025-03-18 23:17:55 +05:30
Piyush Jain
f7a9f86693 - move rds and elasticache to specific files
- change successfulJobsHistory to 0
- add cloudwatch alarms for rds, elb, sqs and dynamodb
- change elasticache to serverless and update secrets
2025-03-18 23:11:51 +05:30
93 changed files with 1147 additions and 1822 deletions

View File

@@ -2,14 +2,21 @@ import { ActionClassesTable } from "@/app/(app)/environments/[environmentId]/act
import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData";
import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading";
import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
export const metadata: Metadata = {
@@ -18,24 +25,51 @@ export const metadata: Metadata = {
const Page = async (props) => {
const params = await props.params;
const { isReadOnly, project, isBilling, environment } = await getEnvironmentAuth(params.environmentId);
const session = await getServerSession(authOptions);
const t = await getTranslate();
const [actionClasses] = await Promise.all([getActionClasses(params.environmentId)]);
const [actionClasses, organization, project] = await Promise.all([
getActionClasses(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
]);
const locale = await findMatchingLocale();
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!project) {
throw new Error(t("common.project_not_found"));
}
const environments = await getEnvironments(project.id);
const currentEnvironment = environments.find((env) => env.id === params.environmentId);
if (!currentEnvironment) {
throw new Error(t("common.environment_not_found"));
}
const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0];
const otherEnvActionClasses = await getActionClasses(otherEnvironment.id);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, project.id);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const isReadOnly = isMember && hasReadAccess;
const renderAddActionButton = () => (
<AddActionModal
environmentId={params.environmentId}
@@ -48,7 +82,7 @@ const Page = async (props) => {
<PageContentWrapper>
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
<ActionClassesTable
environment={environment}
environment={currentEnvironment}
otherEnvironment={otherEnvironment}
otherEnvActionClasses={otherEnvActionClasses}
environmentId={params.environmentId}

View File

@@ -1,14 +1,21 @@
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { getAirtableTables } from "@formbricks/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
@@ -17,25 +24,48 @@ const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID;
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations] = await Promise.all([
const [session, surveys, integrations, environment] = await Promise.all([
getServerSession(authOptions),
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getEnvironment(params.environmentId),
]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
(integration): integration is TIntegrationAirtable => integration.type === "airtable"
);
let airtableArray: TIntegrationItem[] = [];
if (airtableIntegration?.config.key) {
if (airtableIntegration && airtableIntegration.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
}
const locale = await findMatchingLocale();
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
project.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isReadOnly) {
redirect("./");
}

View File

@@ -1,10 +1,13 @@
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import {
GOOGLE_SHEETS_CLIENT_ID,
@@ -12,7 +15,11 @@ import {
GOOGLE_SHEETS_REDIRECT_URL,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
@@ -20,20 +27,43 @@ const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, integrations] = await Promise.all([
const [session, surveys, integrations, environment] = await Promise.all([
getServerSession(authOptions),
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getEnvironment(params.environmentId),
]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
);
const locale = await findMatchingLocale();
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
project.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isReadOnly) {
redirect("./");
}

View File

@@ -1,10 +1,13 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import {
NOTION_AUTH_URL,
@@ -13,8 +16,12 @@ import {
NOTION_REDIRECT_URI,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getNotionDatabases } from "@formbricks/lib/notion/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
@@ -27,20 +34,44 @@ const Page = async (props) => {
NOTION_AUTH_URL &&
NOTION_REDIRECT_URI
);
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, notionIntegration] = await Promise.all([
const [session, surveys, notionIntegration, environment] = await Promise.all([
getServerSession(authOptions),
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"),
getEnvironment(params.environmentId),
]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
}
const locale = await findMatchingLocale();
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
project.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isReadOnly) {
redirect("./");
}

View File

@@ -9,40 +9,71 @@ import notionLogo from "@/images/notion.png";
import SlackLogo from "@/images/slacklogo.png";
import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { Card } from "@/modules/ui/components/integration-card";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import Image from "next/image";
import { redirect } from "next/navigation";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { TIntegrationType } from "@formbricks/types/integration";
const Page = async (props) => {
const params = await props.params;
const environmentId = params.environmentId;
const t = await getTranslate();
const { isReadOnly, environment, isBilling } = await getEnvironmentAuth(params.environmentId);
const [
environment,
integrations,
organization,
session,
userWebhookCount,
zapierWebhookCount,
makeWebhookCount,
n8nwebhookCount,
activePiecesWebhookCount,
] = await Promise.all([
getIntegrations(params.environmentId),
getWebhookCountBySource(params.environmentId, "user"),
getWebhookCountBySource(params.environmentId, "zapier"),
getWebhookCountBySource(params.environmentId, "make"),
getWebhookCountBySource(params.environmentId, "n8n"),
getWebhookCountBySource(params.environmentId, "activepieces"),
getEnvironment(environmentId),
getIntegrations(environmentId),
getOrganizationByEnvironmentId(params.environmentId),
getServerSession(authOptions),
getWebhookCountBySource(environmentId, "user"),
getWebhookCountBySource(environmentId, "zapier"),
getWebhookCountBySource(environmentId, "make"),
getWebhookCountBySource(environmentId, "n8n"),
getWebhookCountBySource(environmentId, "activepieces"),
]);
const isIntegrationConnected = (type: TIntegrationType) =>
integrations.some((integration) => integration.type === type);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
@@ -213,7 +244,7 @@ const Page = async (props) => {
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
docsText: t("common.docs"),
docsNewTab: true,
connectHref: `/environments/${params.environmentId}/project/app-connection`,
connectHref: `/environments/${environmentId}/project/app-connection`,
connectText: t("common.connect"),
connectNewTab: false,
label: "Javascript SDK",

View File

@@ -1,13 +1,20 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
@@ -16,16 +23,40 @@ const Page = async (props) => {
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
const t = await getTranslate();
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, slackIntegration] = await Promise.all([
const [session, surveys, slackIntegration, environment] = await Promise.all([
getServerSession(authOptions),
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "slack"),
getEnvironment(params.environmentId),
]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const locale = await findMatchingLocale();
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
project.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
if (isReadOnly) {
redirect("./");
}

View File

@@ -4,7 +4,7 @@ import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
@@ -49,10 +49,7 @@ const EnvLayout = async (props: {
}
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
if (!membership) {
throw new Error(t("common.membership_not_found"));
}
if (!membership) return notFound();
return (
<>

View File

@@ -157,10 +157,6 @@ const Page = async (props) => {
throw new Error(t("common.user_not_found"));
}
if (!memberships) {
throw new Error(t("common.membership_not_found"));
}
if (user?.notificationSettings) {
user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships);
}

View File

@@ -1,14 +1,18 @@
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@formbricks/lib/organization/service";
import {
getOrganizationByEnvironmentId,
getOrganizationsWhereUserIsSingleOwner,
} from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount";
@@ -21,16 +25,20 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const { environmentId } = params;
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const { session } = await getEnvironmentAuth(params.environmentId);
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(session.user.id);
const user = session?.user ? await getUser(session.user.id) : null;
if (!user) {
throw new Error(t("common.user_not_found"));
}
const user = session && session.user ? await getUser(session.user.id) : null;
return (
<PageContentWrapper>

View File

@@ -1,14 +1,18 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { CheckIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
const Page = async (props) => {
const params = await props.params;
@@ -17,8 +21,20 @@ const Page = async (props) => {
notFound();
}
const { isMember, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
const session = await getServerSession(authOptions);
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const isPricingDisabled = isMember;
if (isPricingDisabled) {

View File

@@ -60,7 +60,7 @@ export const ShareEmbedSurvey = ({
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id);
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
const [surveyUrl, setSurveyUrl] = useState("");
const [surveyUrl, setSurveyUrl] = useState(webAppUrl + "/s/" + survey.id);
useEffect(() => {
if (survey.type !== "link") {

View File

@@ -1,3 +0,0 @@
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route";
export { GET };

View File

@@ -1,4 +0,0 @@
import { ContactSurveyPage, generateMetadata } from "@/modules/survey/link/contact-survey/page";
export { generateMetadata };
export default ContactSurveyPage;

View File

@@ -6,17 +6,10 @@ interface SurveyLinkDisplayProps {
export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
return (
<>
{surveyUrl ? (
<Input
autoFocus={true}
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
value={surveyUrl}
/>
) : (
//loading state
<div className="mt-2 h-10 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-4 py-2 text-slate-800 caret-transparent"></div>
)}
</>
<Input
autoFocus={true}
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
defaultValue={surveyUrl}
/>
);
};

View File

@@ -80,7 +80,6 @@ export const ShareSurveyLink = ({
<Button
title={t("environments.surveys.preview_survey_in_a_new_tab")}
aria-label={t("environments.surveys.preview_survey_in_a_new_tab")}
disabled={!surveyUrl}
onClick={() => {
let previewUrl = surveyUrl;
if (previewUrl.includes("?")) {
@@ -94,7 +93,6 @@ export const ShareSurveyLink = ({
<SquareArrowOutUpRight />
</Button>
<Button
disabled={!surveyUrl}
variant="secondary"
title={t("environments.surveys.copy_survey_link_to_clipboard")}
aria-label={t("environments.surveys.copy_survey_link_to_clipboard")}
@@ -110,13 +108,11 @@ export const ShareSurveyLink = ({
title={t("environments.surveys.summary.download_qr_code")}
aria-label={t("environments.surveys.summary.download_qr_code")}
size={"icon"}
disabled={!surveyUrl}
onClick={downloadQRCode}>
<QrCode style={{ width: "24px", height: "24px" }} />
</Button>
{survey.singleUse?.enabled && (
<Button
disabled={!surveyUrl}
title="Regenerate single use survey link"
aria-label="Regenerate single use survey link"
onClick={generateNewSingleUseLink}>

View File

@@ -105,7 +105,7 @@ export const ResponseNotes = ({
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
isOpen
? "-right-2 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
? "-right-5 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
: unresolvedNotes.length
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"

View File

@@ -1,61 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getContact } from "./contacts";
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findUnique: vi.fn(),
},
},
}));
describe("getContact", () => {
const mockContactId = "cm8fj8ry6000008l5daam88nc";
const mockEnvironmentId = "cm8fj8xt3000108l5art7594h";
const mockContact = {
id: mockContactId,
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns contact when found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact);
const result = await getContact(mockContactId, mockEnvironmentId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: {
id: mockContactId,
environmentId: mockEnvironmentId,
},
select: {
id: true,
},
});
if (result.ok) {
expect(result.data).toEqual(mockContact);
}
});
test("returns null when contact not found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
const result = await getContact(mockContactId, mockEnvironmentId);
expect(prisma.contact.findUnique).toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toEqual({
details: [
{
field: "contact",
issue: "not found",
},
],
type: "not_found",
});
}
});
});

View File

@@ -1,37 +0,0 @@
import { contactCache } from "@/lib/cache/contact";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Contact } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContact = reactCache(async (contactId: string, environmentId: string) =>
cache(
async (): Promise<Result<Pick<Contact, "id">, ApiErrorResponseV2>> => {
try {
const contact = await prisma.contact.findUnique({
where: {
id: contactId,
environmentId,
},
select: {
id: true,
},
});
if (!contact) {
return err({ type: "not_found", details: [{ field: "contact", issue: "not found" }] });
}
return ok(contact);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "contact", issue: error.message }] });
}
},
[`contact-link-getContact-${contactId}-${environmentId}`],
{
tags: [contactCache.tag.byId(contactId), contactCache.tag.byEnvironmentId(environmentId)],
}
)()
);

View File

@@ -1,61 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getResponse } from "./response";
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
findFirst: vi.fn(),
},
},
}));
describe("getResponse", () => {
const mockContactId = "cm8fj8xt3000108l5art7594h";
const mockSurveyId = "cm8fj9962000208l56jcu94i5";
const mockResponse = {
id: "cm8fj9gqp000308l5ab7y800j",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns response when found", async () => {
vi.mocked(prisma.response.findFirst).mockResolvedValue(mockResponse);
const result = await getResponse(mockContactId, mockSurveyId);
expect(prisma.response.findFirst).toHaveBeenCalledWith({
where: {
contactId: mockContactId,
surveyId: mockSurveyId,
},
select: {
id: true,
},
});
if (result.ok) {
expect(result.data).toEqual(mockResponse);
}
});
test("returns null when response not found", async () => {
vi.mocked(prisma.response.findFirst).mockResolvedValue(null);
const result = await getResponse(mockContactId, mockSurveyId);
expect(prisma.response.findFirst).toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toEqual({
details: [
{
field: "response",
issue: "not found",
},
],
type: "not_found",
});
}
});
});

View File

@@ -1,37 +0,0 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Response } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { responseCache } from "@formbricks/lib/response/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getResponse = reactCache(async (contactId: string, surveyId: string) =>
cache(
async (): Promise<Result<Pick<Response, "id">, ApiErrorResponseV2>> => {
try {
const response = await prisma.response.findFirst({
where: {
contactId,
surveyId,
},
select: {
id: true,
},
});
if (!response) {
return err({ type: "not_found", details: [{ field: "response", issue: "not found" }] });
}
return ok(response);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "response", issue: error.message }] });
}
},
[`contact-link-getResponse-${contactId}-${surveyId}`],
{
tags: [responseCache.tag.byId(contactId), responseCache.tag.bySurveyId(surveyId)],
}
)()
);

View File

@@ -1,61 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getSurvey } from "./surveys";
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findUnique: vi.fn(),
},
},
}));
describe("getSurvey", () => {
const mockSurveyId = "cm8fj9psb000408l50e1x4c6f";
const mockSurvey = {
id: mockSurveyId,
type: "web",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns survey when found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValue(mockSurvey);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: {
id: mockSurveyId,
},
select: {
id: true,
type: true,
},
});
if (result.ok) {
expect(result.data).toEqual(mockSurvey);
}
});
test("returns null when survey not found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
const result = await getSurvey(mockSurveyId);
expect(prisma.survey.findUnique).toHaveBeenCalled();
if (!result.ok) {
expect(result.error).toEqual({
details: [
{
field: "survey",
issue: "not found",
},
],
type: "not_found",
});
}
});
});

View File

@@ -1,35 +0,0 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Survey } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getSurvey = reactCache(async (surveyId: string) =>
cache(
async (): Promise<Result<Pick<Survey, "id" | "type">, ApiErrorResponseV2>> => {
try {
const survey = await prisma.survey.findUnique({
where: { id: surveyId },
select: {
id: true,
type: true,
},
});
if (!survey) {
return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] });
}
return ok(survey);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] });
}
},
[`contact-link-getSurvey-${surveyId}`],
{
tags: [surveyCache.tag.byId(surveyId)],
}
)()
);

View File

@@ -1,111 +0,0 @@
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { getContact } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts";
import { getResponse } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response";
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/surveys";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
const ZContactLinkParams = z.object({
surveyId: ZId,
contactId: ZId,
});
export const GET = async (
request: Request,
props: { params: Promise<{ surveyId: string; contactId: string }> }
) =>
authenticatedApiClient({
request,
externalParams: props.params,
schemas: {
params: ZContactLinkParams,
},
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const environmentIdResult = await getEnvironmentId(params.surveyId, false);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error);
}
const environmentId = environmentIdResult.data;
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
}
const surveyResult = await getSurvey(params.surveyId);
if (!surveyResult.ok) {
return handleApiError(request, surveyResult.error);
}
const survey = surveyResult.data;
if (!survey) {
return handleApiError(request, {
type: "not_found",
details: [{ field: "surveyId", issue: "Not found" }],
});
}
if (survey.type !== "link") {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "surveyId", issue: "Not a link survey" }],
});
}
// Check if contact exists and belongs to the environment
const contactResult = await getContact(params.contactId, environmentId);
if (!contactResult.ok) {
return handleApiError(request, contactResult.error);
}
const contact = contactResult.data;
if (!contact) {
return handleApiError(request, {
type: "not_found",
details: [{ field: "contactId", issue: "Not found" }],
});
}
// Check if contact has already responded to this survey
const existingResponseResult = await getResponse(params.contactId, params.surveyId);
if (existingResponseResult.ok) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactId", issue: "Already responded" }],
});
}
const surveyUrlResult = getContactSurveyLink(params.contactId, params.surveyId, 7);
if (!surveyUrlResult.ok) {
return handleApiError(request, surveyUrlResult.error);
}
return responses.successResponse({ data: { surveyUrl: surveyUrlResult.data } });
},
});

View File

@@ -3,9 +3,3 @@ export type TOidcNameFields = {
family_name?: string;
preferred_username?: string;
};
export type TSamlNameFields = {
name?: string;
firstName?: string;
lastName?: string;
};

View File

@@ -1,14 +1,18 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { PROJECT_FEATURE_KEYS, STRIPE_PRICE_LOOKUP_KEYS } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@formbricks/lib/organization/service";
import { getOrganizationProjectsCount } from "@formbricks/lib/project/service";
import { PricingTable } from "./components/pricing-table";
@@ -16,19 +20,29 @@ import { PricingTable } from "./components/pricing-table";
export const PricingPage = async (props) => {
const params = await props.params;
const t = await getTranslate();
const { organization, isMember, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!IS_FORMBRICKS_CLOUD) {
notFound();
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.not_authorized"));
}
const [peopleCount, responseCount, projectCount] = await Promise.all([
getMonthlyActiveOrganizationPeopleCount(organization.id),
getMonthlyOrganizationResponseCount(organization.id),
getOrganizationProjectsCount(organization.id),
]);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const hasBillingRights = !isMember;
return (

View File

@@ -1,12 +1,20 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
import { DeleteContactButton } from "@/modules/ee/contacts/[contactId]/components/delete-contact-button";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { ResponseSection } from "./components/response-section";
@@ -15,19 +23,45 @@ export const SingleContactPage = async (props: {
}) => {
const params = await props.params;
const t = await getTranslate();
const [environment, environmentTags, project, session, organization, contact, contactAttributes] =
await Promise.all([
getEnvironment(params.environmentId),
getTagsByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions),
getOrganizationByEnvironmentId(params.environmentId),
getContact(params.contactId),
getContactAttributes(params.contactId),
]);
const { environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const [environmentTags, contact, contactAttributes] = await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getContact(params.contactId),
getContactAttributes(params.contactId),
]);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!contact) {
throw new Error(t("environments.contacts.contact_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
const getDeletePersonButton = () => {
return (
<DeleteContactButton

View File

@@ -1,188 +0,0 @@
import jwt from "jsonwebtoken";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ENCRYPTION_KEY, WEBAPP_URL } from "@formbricks/lib/constants";
import * as crypto from "@formbricks/lib/crypto";
import * as contactSurveyLink from "./contact-survey-link";
// Mock all modules needed (this gets hoisted to the top of the file)
vi.mock("jsonwebtoken", () => ({
default: {
sign: vi.fn(),
verify: vi.fn(),
},
}));
// Mock constants - MUST be a literal object without using variables
vi.mock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!",
WEBAPP_URL: "https://test.formbricks.com",
}));
vi.mock("@formbricks/lib/crypto", () => ({
symmetricEncrypt: vi.fn(),
symmetricDecrypt: vi.fn(),
}));
describe("Contact Survey Link", () => {
const mockContactId = "contact-123";
const mockSurveyId = "survey-456";
const mockToken = "mock.jwt.token";
const mockEncryptedContactId = "encrypted-contact-id";
const mockEncryptedSurveyId = "encrypted-survey-id";
beforeEach(() => {
vi.clearAllMocks();
// Setup default mocks
vi.mocked(crypto.symmetricEncrypt).mockImplementation((value) =>
value === mockContactId ? mockEncryptedContactId : mockEncryptedSurveyId
);
vi.mocked(crypto.symmetricDecrypt).mockImplementation((value) => {
if (value === mockEncryptedContactId) return mockContactId;
if (value === mockEncryptedSurveyId) return mockSurveyId;
return value;
});
vi.mocked(jwt.sign).mockReturnValue(mockToken as any);
vi.mocked(jwt.verify).mockReturnValue({
contactId: mockEncryptedContactId,
surveyId: mockEncryptedSurveyId,
} as any);
});
describe("getContactSurveyLink", () => {
it("creates a survey link with encrypted contact and survey IDs", () => {
const result = contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
// Verify encryption was called for both IDs
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockContactId, ENCRYPTION_KEY);
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockSurveyId, ENCRYPTION_KEY);
// Verify JWT sign was called with correct payload
expect(jwt.sign).toHaveBeenCalledWith(
{
contactId: mockEncryptedContactId,
surveyId: mockEncryptedSurveyId,
},
ENCRYPTION_KEY,
{ algorithm: "HS256" }
);
// Verify the returned URL
expect(result).toEqual({
ok: true,
data: `${WEBAPP_URL}/c/${mockToken}`,
});
});
it("adds expiration to the token when expirationDays is provided", () => {
const expirationDays = 7;
contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId, expirationDays);
// Verify JWT sign was called with expiration
expect(jwt.sign).toHaveBeenCalledWith(
{
contactId: mockEncryptedContactId,
surveyId: mockEncryptedSurveyId,
},
ENCRYPTION_KEY,
{ algorithm: "HS256", expiresIn: "7d" }
);
});
it("throws an error when ENCRYPTION_KEY is not available", async () => {
// Reset modules so the new mock is used by the module under test
vi.resetModules();
// Remock constants to simulate missing ENCRYPTION_KEY
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined,
WEBAPP_URL: "https://test.formbricks.com",
}));
// Reimport the modules so they pick up the new mock
const { getContactSurveyLink } = await import("./contact-survey-link");
const result = getContactSurveyLink(mockContactId, mockSurveyId);
expect(result).toEqual({
ok: false,
error: {
type: "internal_server_error",
message: "Encryption key not found - cannot create personalized survey link",
},
});
});
});
describe("verifyContactSurveyToken", () => {
it("verifies and decrypts a valid token", () => {
const result = contactSurveyLink.verifyContactSurveyToken(mockToken);
// Verify JWT verify was called
expect(jwt.verify).toHaveBeenCalledWith(mockToken, ENCRYPTION_KEY);
// Check the decrypted result
expect(result).toEqual({
ok: true,
data: {
contactId: mockContactId,
surveyId: mockSurveyId,
},
});
});
it("throws an error when token verification fails", () => {
vi.mocked(jwt.verify).mockImplementation(() => {
throw new Error("Token verification failed");
});
const result = contactSurveyLink.verifyContactSurveyToken(mockToken);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
});
}
});
it("throws an error when token has invalid format", () => {
// Mock JWT.verify to return an incomplete payload
vi.mocked(jwt.verify).mockReturnValue({
// Missing surveyId
contactId: mockEncryptedContactId,
} as any);
// Suppress console.error for this test
vi.spyOn(console, "error").mockImplementation(() => {});
const result = contactSurveyLink.verifyContactSurveyToken(mockToken);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
});
}
});
it("throws an error when ENCRYPTION_KEY is not available", async () => {
vi.resetModules();
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined,
WEBAPP_URL: "https://test.formbricks.com",
}));
const { verifyContactSurveyToken } = await import("./contact-survey-link");
const result = verifyContactSurveyToken(mockToken);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "internal_server_error",
message: "Encryption key not found - cannot verify survey token",
});
}
});
});
});

View File

@@ -1,82 +0,0 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import jwt from "jsonwebtoken";
import { ENCRYPTION_KEY, WEBAPP_URL } from "@formbricks/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto";
import { Result, err, ok } from "@formbricks/types/error-handlers";
// Creates an encrypted personalized survey link for a contact
export const getContactSurveyLink = (
contactId: string,
surveyId: string,
expirationDays?: number
): Result<string, ApiErrorResponseV2> => {
if (!ENCRYPTION_KEY) {
return err({
type: "internal_server_error",
message: "Encryption key not found - cannot create personalized survey link",
});
}
// Encrypt the contact and survey IDs
const encryptedContactId = symmetricEncrypt(contactId, ENCRYPTION_KEY);
const encryptedSurveyId = symmetricEncrypt(surveyId, ENCRYPTION_KEY);
// Create JWT payload with encrypted IDs
const payload = {
contactId: encryptedContactId,
surveyId: encryptedSurveyId,
};
// Set token options
const tokenOptions: jwt.SignOptions = {
algorithm: "HS256",
};
// Add expiration if specified
if (expirationDays !== undefined && expirationDays > 0) {
tokenOptions.expiresIn = `${expirationDays}d`;
}
// Sign the token with ENCRYPTION_KEY using SHA256
const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions);
// Return the personalized URL
return ok(`${WEBAPP_URL}/c/${token}`);
};
// Validates and decrypts a contact survey JWT token
export const verifyContactSurveyToken = (
token: string
): Result<{ contactId: string; surveyId: string }, ApiErrorResponseV2> => {
if (!ENCRYPTION_KEY) {
return err({
type: "internal_server_error",
message: "Encryption key not found - cannot verify survey token",
});
}
try {
// Verify the token
const decoded = jwt.verify(token, ENCRYPTION_KEY) as { contactId: string; surveyId: string };
if (!decoded || !decoded.contactId || !decoded.surveyId) {
throw err("Invalid token format");
}
// Decrypt the contact and survey IDs
const contactId = symmetricDecrypt(decoded.contactId, ENCRYPTION_KEY);
const surveyId = symmetricDecrypt(decoded.surveyId, ENCRYPTION_KEY);
return ok({
contactId,
surveyId,
});
} catch (error) {
console.error("Error verifying contact survey token:", error);
return err({
type: "bad_request",
message: "Invalid or expired survey token",
details: [{ field: "token", issue: "Invalid or expired survey token" }],
});
}
};

View File

@@ -1,14 +1,21 @@
import { contactCache } from "@/lib/cache/contact";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { UploadContactsCSVButton } from "@/modules/ee/contacts/components/upload-contacts-button";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getContacts } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { IS_FORMBRICKS_CLOUD, ITEMS_PER_PAGE } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { ContactDataView } from "./components/contact-data-view";
import { ContactsSecondaryNavigation } from "./components/contacts-secondary-navigation";
@@ -17,14 +24,39 @@ export const ContactsPage = async ({
}: {
params: Promise<{ environmentId: string }>;
}) => {
const params = await paramsProps;
const { environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const t = await getTranslate();
const params = await paramsProps;
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Session not found");
}
const isContactsEnabled = await getIsContactsEnabled();
const [environment, product] = await Promise.all([
getEnvironment(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
]);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!product) {
throw new Error(t("common.product_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
product.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const productPermission = await getProjectPermissionByUserId(session.user.id, product.id);
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
const isReadOnly = isMember && hasReadAccess;
const contactAttributeKeys = await getContactAttributeKeys(params.environmentId);
const initialContacts = await getContacts(params.environmentId, 0);

View File

@@ -1,14 +1,22 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ContactsSecondaryNavigation } from "@/modules/ee/contacts/components/contacts-secondary-navigation";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { SegmentTable } from "@/modules/ee/contacts/segments/components/segment-table";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { CreateSegmentModal } from "./components/create-segment-modal";
export const SegmentsPage = async ({
@@ -18,14 +26,42 @@ export const SegmentsPage = async ({
}) => {
const params = await paramsProps;
const t = await getTranslate();
const { isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [segments, contactAttributeKeys] = await Promise.all([
const [session, environment, product, segments, contactAttributeKeys, organization] = await Promise.all([
getServerSession(authOptions),
getEnvironment(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getSegments(params.environmentId),
getContactAttributeKeys(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
]);
if (!session) {
throw new Error("Session not found");
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!product) {
throw new Error(t("common.product_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session?.user.id,
product.organizationId
);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const productPermission = await getProjectPermissionByUserId(session.user.id, product.id);
const { hasReadAccess } = getTeamPermissionFlags(productPermission);
const isReadOnly = isMember && hasReadAccess;
const isContactsEnabled = await getIsContactsEnabled();
if (!segments) {

View File

@@ -1,28 +1,58 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { EditLanguage } from "@/modules/ee/multi-language-surveys/components/edit-language";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
export const LanguagesPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const project = await getProjectByEnvironmentId(params.environmentId);
const { organization, session, project, isReadOnly } = await getEnvironmentAuth(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const organization = await getOrganization(project?.organizationId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization.billing.plan);
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Session not found");
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error("User not found");
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.project_configuration")}>

View File

@@ -1,7 +1,7 @@
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { createUser } from "@/modules/auth/lib/user";
import { TOidcNameFields, TSamlNameFields } from "@/modules/auth/types/auth";
import { TOidcNameFields } from "@/modules/auth/types/auth";
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
import type { IdentityProvider } from "@prisma/client";
import type { Account } from "next-auth";
@@ -93,15 +93,6 @@ export const handleSSOCallback = async ({ user, account }: { user: TUser; accoun
}
}
if (provider === "saml") {
const samlUser = user as TUser & TSamlNameFields;
if (samlUser.name) {
userName = samlUser.name;
} else if (samlUser.firstName || samlUser.lastName) {
userName = `${samlUser.firstName} ${samlUser.lastName}`;
}
}
const userProfile = await createUser({
name:
userName ||

View File

@@ -1,16 +1,37 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { AccessView } from "@/modules/ee/teams/project-teams/components/access-view";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getTeamsByProjectId } from "./lib/team";
export const ProjectTeams = async (props: { params: Promise<{ environmentId: string }> }) => {
const t = await getTranslate();
const params = await props.params;
const [project, session, organization] = await Promise.all([
getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions),
getOrganizationByEnvironmentId(params.environmentId),
]);
const { project, isOwner, isManager } = await getEnvironmentAuth(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isOwner, isManager } = getAccessFlags(currentUserMembership?.role);
const teams = await getTeamsByProjectId(project.id);

View File

@@ -1,21 +0,0 @@
import { Text } from "@react-email/components";
import { cn } from "@formbricks/lib/cn";
interface QuestionHeaderProps {
headline: string;
subheader?: string;
className?: string;
}
export function QuestionHeader({ headline, subheader, className }: QuestionHeaderProps): React.JSX.Element {
return (
<>
<Text className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
{headline}
</Text>
{subheader && (
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">{subheader}</Text>
)}
</>
);
}

View File

@@ -18,10 +18,8 @@ import { cn } from "@formbricks/lib/cn";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { isLight, mixColor } from "@formbricks/lib/utils/colors";
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
import { type TSurvey, TSurveyQuestionTypeEnum, type TSurveyStyling } from "@formbricks/types/surveys/types";
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
import { QuestionHeader } from "./email-question-header";
interface PreviewEmailTemplateProps {
survey: TSurvey;
@@ -56,15 +54,19 @@ export async function PreviewEmailTemplate({
const urlWithPrefilling = `${surveyUrl}?preview=true&skipPrefilled=true&`;
const defaultLanguageCode = "default";
const firstQuestion = survey.questions[0];
const headline = parseRecallInfo(getLocalizedValue(firstQuestion.headline, defaultLanguageCode));
const subheader = parseRecallInfo(getLocalizedValue(firstQuestion.subheader, defaultLanguageCode));
const brandColor = styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
switch (firstQuestion.type) {
case TSurveyQuestionTypeEnum.OpenText:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Section className="border-input-border-color rounded-custom mt-4 block h-20 w-full border border-solid bg-slate-50" />
<EmailFooter />
</EmailTemplateWrapper>
@@ -72,7 +74,9 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.Consent:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
<Text className="text-question-color m-0 block text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Container className="text-question-color m-0 text-sm font-normal leading-6">
<div
className="m-0 p-0"
@@ -111,7 +115,12 @@ export async function PreviewEmailTemplate({
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Section className="w-full justify-center">
<QuestionHeader headline={headline} subheader={subheader} />
<Text className="text-question-color m-0 block w-full text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 block w-full p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Container className="mx-0 mt-4 w-full items-center justify-center">
<Section
className={cn("w-full overflow-hidden", {
@@ -162,7 +171,9 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.CTA:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
<Text className="text-question-color m-0 block text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Container className="text-question-color ml-0 mt-2 text-sm font-normal leading-6">
<div
className="m-0 p-0"
@@ -196,7 +207,12 @@ export async function PreviewEmailTemplate({
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Section className="w-full">
<QuestionHeader headline={headline} subheader={subheader} />
<Text className="text-question-color m-0 block text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Container className="mx-0 mt-4 w-full items-center justify-center">
<Section className="w-full overflow-hidden">
<Row>
@@ -261,7 +277,12 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Section
@@ -277,7 +298,12 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.Ranking:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Section
@@ -293,7 +319,12 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Link
@@ -310,7 +341,12 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Section className="mx-0">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
@@ -337,7 +373,12 @@ export async function PreviewEmailTemplate({
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Container>
<QuestionHeader headline={headline} subheader={subheader} />
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<EmailButton
className={cn(
"bg-brand-color rounded-custom mx-auto block w-max cursor-pointer appearance-none px-6 py-3 text-sm font-medium",
@@ -352,7 +393,12 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.Date:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Section className="border-input-border-color bg-input-color rounded-custom mt-4 flex h-12 w-full items-center justify-center border border-solid">
<CalendarDaysIcon className="text-question-color inline h-4 w-4" />
<Text className="text-question-color inline text-sm font-medium">
@@ -365,7 +411,12 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.Matrix:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, "default")}
</Text>
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, "default")}
</Text>
<Container className="mx-0">
<Section className="w-full table-auto">
<Row>
@@ -409,7 +460,12 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.ContactInfo:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
{["First Name", "Last Name", "Email", "Phone", "Company"].map((label) => (
<Section
className="border-input-border-color bg-input-color rounded-custom mt-4 block h-10 w-full border border-solid py-2 pl-2 text-slate-400"
@@ -424,7 +480,12 @@ export async function PreviewEmailTemplate({
case TSurveyQuestionTypeEnum.FileUpload:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Text className="text-question-color m-0 mr-8 block p-0 text-base font-semibold leading-6">
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Section className="border-input-border-color rounded-custom mt-4 flex h-24 w-full items-center justify-center border border-dashed bg-slate-50">
<Container className="mx-auto flex items-center text-center">
<UploadIcon className="mt-6 inline h-5 w-5 text-slate-400" />

View File

@@ -1,76 +0,0 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { cache } from "react";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { TEnvironmentAuth } from "../types/environment-auth";
/**
* Common utility to fetch environment data and perform authorization checks
*
* Usage:
* const { environment, project, isReadOnly } = await getEnvironmentAuth(params.environmentId);
*/
export const getEnvironmentAuth = cache(async (environmentId: string): Promise<TEnvironmentAuth> => {
const t = await getTranslate();
// Perform all fetches in parallel
const [environment, project, session, organization] = await Promise.all([
getEnvironment(environmentId),
getProjectByEnvironmentId(environmentId),
getServerSession(authOptions),
getOrganizationByEnvironmentId(environmentId),
]);
if (!project) {
throw new Error(t("common.project_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
if (!currentUserMembership) {
throw new Error(t("common.membership_not_found"));
}
const { isMember, isOwner, isManager, isBilling } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasReadAccess, hasReadWriteAccess, hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
return {
environment,
project,
organization,
session,
currentUserMembership,
projectPermission,
isMember,
isOwner,
isManager,
isBilling,
hasReadAccess,
hasReadWriteAccess,
hasManageAccess,
isReadOnly,
};
});

View File

@@ -1,29 +0,0 @@
import { ZTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { z } from "zod";
import { ZEnvironment } from "@formbricks/types/environment";
import { ZMembership } from "@formbricks/types/memberships";
import { ZOrganization } from "@formbricks/types/organizations";
import { ZProject } from "@formbricks/types/project";
import { ZUser } from "@formbricks/types/user";
export const ZEnvironmentAuth = z.object({
environment: ZEnvironment,
project: ZProject,
organization: ZOrganization,
session: z.object({
user: ZUser.pick({ id: true }),
expires: z.string(),
}),
currentUserMembership: ZMembership,
projectPermission: ZTeamPermission.nullable(),
isMember: z.boolean(),
isOwner: z.boolean(),
isManager: z.boolean(),
isBilling: z.boolean(),
hasReadAccess: z.boolean(),
hasReadWriteAccess: z.boolean(),
hasManageAccess: z.boolean(),
isReadOnly: z.boolean(),
});
export type TEnvironmentAuth = z.infer<typeof ZEnvironmentAuth>;

View File

@@ -1,4 +1,6 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { AddWebhookButton } from "@/modules/integrations/webhooks/components/add-webhook-button";
import { WebhookRowData } from "@/modules/integrations/webhooks/components/webhook-row-data";
import { WebhookTable } from "@/modules/integrations/webhooks/components/webhook-table";
@@ -8,20 +10,46 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getSurveys } from "@formbricks/lib/survey/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
export const WebhooksPage = async (props) => {
const params = await props.params;
const t = await getTranslate();
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [webhooks, surveys] = await Promise.all([
const [session, organization, webhooks, surveys, environment] = await Promise.all([
getServerSession(authOptions),
getOrganizationByEnvironmentId(params.environmentId),
getWebhooks(params.environmentId),
getSurveys(params.environmentId, 200), // HOTFIX: not getting all surveys for now since it's maxing out the prisma accelerate limit
getEnvironment(params.environmentId),
]);
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
const renderAddWebhookButton = () => <AddWebhookButton environment={environment} surveys={surveys} />;
const locale = await findMatchingLocale();

View File

@@ -1,6 +1,5 @@
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { EnvironmentIdField } from "@/modules/projects/settings/(setup)/components/environment-id-field";
import { SetupInstructions } from "@/modules/projects/settings/(setup)/components/setup-instructions";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
@@ -9,12 +8,24 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
export const AppConnectionPage = async (props) => {
const params = await props.params;
const t = await getTranslate();
const [environment, organization] = await Promise.all([
getEnvironment(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
]);
const { environment } = await getEnvironmentAuth(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
return (
<PageContentWrapper>

View File

@@ -1,22 +1,54 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { EnvironmentNotice } from "@/modules/ui/components/environment-notice";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { ApiKeyList } from "./components/api-key-list";
export const APIKeysPage = async (props) => {
const params = await props.params;
const t = await getTranslate();
const [session, environment, organization, project] = await Promise.all([
getServerSession(authOptions),
getEnvironment(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
]);
// Use the new utility to get all required data with authorization checks
const { environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
const locale = await findMatchingLocale();
if (!project) {
throw new Error(t("common.project_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.project_configuration")}>

View File

@@ -1,13 +1,19 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import packageJson from "@/package.json";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getProjects } from "@formbricks/lib/project/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId, getProjects } from "@formbricks/lib/project/service";
import { DeleteProject } from "./components/delete-project";
import { EditProjectNameForm } from "./components/edit-project-name-form";
import { EditWaitingTimeForm } from "./components/edit-waiting-time-form";
@@ -15,13 +21,32 @@ import { EditWaitingTimeForm } from "./components/edit-waiting-time-form";
export const GeneralSettingsPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const [project, session, organization] = await Promise.all([
getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions),
getOrganizationByEnvironmentId(params.environmentId),
]);
const { isReadOnly, isOwner, isManager, project, organization } = await getEnvironmentAuth(
params.environmentId
);
if (!project) {
throw new Error(t("common.project_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const organizationProjects = await getProjects(organization.id);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { isMember, isOwner, isManager } = getAccessFlags(currentUserMembership?.role);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
const isOwnerOrManager = isOwner || isManager;
return (

View File

@@ -1,6 +1,12 @@
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getTranslate } from "@/tolgee/server";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
export const metadata: Metadata = {
title: "Configuration",
@@ -8,20 +14,35 @@ export const metadata: Metadata = {
export const ProjectSettingsLayout = async (props) => {
const params = await props.params;
const { children } = props;
try {
// Use the new utility to get all required data with authorization checks
const { isBilling } = await getEnvironmentAuth(params.environmentId);
const t = await getTranslate();
// Redirect billing users
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const [organization, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
return children;
} catch (error) {
// The error boundary will catch this
throw error;
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error("Project not found");
}
return children;
};

View File

@@ -1,32 +1,52 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { BrandingSettingsCard } from "@/modules/ee/whitelabel/remove-branding/components/branding-settings-card";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { EditLogo } from "@/modules/projects/settings/look/components/edit-logo";
import { getProjectByEnvironmentId } from "@/modules/projects/settings/look/lib/project";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { cn } from "@formbricks/lib/cn";
import { SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { EditPlacementForm } from "./components/edit-placement-form";
import { ThemeStyling } from "./components/theme-styling";
export const ProjectLookSettingsPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const { isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
const project = await getProjectByEnvironmentId(params.environmentId);
const [session, organization, project] = await Promise.all([
getServerSession(authOptions),
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
]);
if (!project) {
throw new Error("Project not found");
throw new Error(t("common.project_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const canRemoveBranding = await getWhiteLabelPermission(organization.billing.plan);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.project_configuration")}>
@@ -40,7 +60,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
environmentId={params.environmentId}
project={project}
colors={SURVEY_BG_COLORS}
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false}
isReadOnly={isReadOnly}
/>
</SettingsCard>

View File

@@ -1,9 +1,17 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getTagsOnResponsesCount } from "@formbricks/lib/tagOnResponse/service";
import { EditTagsWrapper } from "./components/edit-tags-wrapper";
@@ -11,14 +19,42 @@ import { EditTagsWrapper } from "./components/edit-tags-wrapper";
export const TagsPage = async (props) => {
const params = await props.params;
const t = await getTranslate();
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [tags, environmentTagsCount] = await Promise.all([
const [tags, environmentTagsCount, organization, session, project] = await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getTagsOnResponsesCount(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
getServerSession(authOptions),
getProjectByEnvironmentId(params.environmentId),
]);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!project) {
throw new Error(t("common.project_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.project_configuration")}>

View File

@@ -30,7 +30,6 @@ interface LinkSurveyProps {
IS_FORMBRICKS_CLOUD: boolean;
locale: string;
isPreview: boolean;
contactId?: string;
}
export const LinkSurvey = ({
@@ -49,7 +48,6 @@ export const LinkSurvey = ({
IS_FORMBRICKS_CLOUD,
locale,
isPreview,
contactId,
}: LinkSurveyProps) => {
const responseId = singleUseResponse?.id;
const searchParams = useSearchParams();
@@ -200,7 +198,6 @@ export const LinkSurvey = ({
singleUseId={singleUseId}
singleUseResponseId={responseId}
getSetIsResponseSendingFinished={(_f: (value: boolean) => void) => {}}
contactId={contactId}
/>
</LinkSurveyWrapper>
);

View File

@@ -25,7 +25,6 @@ interface PinScreenProps {
isEmbed: boolean;
locale: string;
isPreview: boolean;
contactId?: string;
}
export const PinScreen = (props: PinScreenProps) => {
@@ -44,7 +43,6 @@ export const PinScreen = (props: PinScreenProps) => {
isEmbed,
locale,
isPreview,
contactId,
} = props;
const [localPinEntry, setLocalPinEntry] = useState<string>("");
@@ -77,7 +75,7 @@ export const PinScreen = (props: PinScreenProps) => {
if (isValidPin) {
setLoading(true);
const response = await validateSurveyPinAction({ surveyId, pin: localPinEntry });
if (response?.data?.survey) {
if (response?.data) {
setSurvey(response.data.survey);
} else {
const errorMessage = getFormattedErrorMessage(response);
@@ -127,7 +125,6 @@ export const PinScreen = (props: PinScreenProps) => {
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
contactId={contactId}
/>
);
};

View File

@@ -10,7 +10,7 @@ export const SurveyInactive = async ({
status,
surveyClosedMessage,
}: {
status: "paused" | "completed" | "link invalid" | "scheduled" | "response submitted";
status: "paused" | "completed" | "link invalid" | "scheduled";
surveyClosedMessage?: TSurveyClosedMessage | null;
}) => {
const t = await getTranslate();
@@ -18,14 +18,12 @@ export const SurveyInactive = async ({
paused: <PauseCircleIcon className="h-20 w-20" />,
completed: <CheckCircle2Icon className="h-20 w-20" />,
"link invalid": <HelpCircleIcon className="h-20 w-20" />,
"response submitted": <CheckCircle2Icon className="h-20 w-20" />,
};
const descriptions = {
paused: t("s.paused"),
completed: t("s.completed"),
"link invalid": t("s.link_invalid"),
"response submitted": t("s.response_submitted"),
};
return (
@@ -43,13 +41,11 @@ export const SurveyInactive = async ({
? surveyClosedMessage.subheading
: descriptions[status]}
</p>
{!(status === "completed" && surveyClosedMessage) &&
status !== "link invalid" &&
status !== "response submitted" && (
<Button className="mt-2" asChild>
<Link href="https://formbricks.com">{t("s.create_your_own")}</Link>
</Button>
)}
{!(status === "completed" && surveyClosedMessage) && status !== "link invalid" && (
<Button className="mt-2" asChild>
<Link href="https://formbricks.com">{t("s.create_your_own")}</Link>
</Button>
)}
</div>
<div>
<Link href="https://formbricks.com">

View File

@@ -1,144 +0,0 @@
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
import { PinScreen } from "@/modules/survey/link/components/pin-screen";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { type Response } from "@prisma/client";
import { notFound } from "next/navigation";
import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { TSurvey } from "@formbricks/types/surveys/types";
interface SurveyRendererProps {
survey: TSurvey;
searchParams: {
verify?: string;
lang?: string;
embed?: string;
preview?: string;
};
singleUseId?: string;
singleUseResponse?: Pick<Response, "id" | "finished"> | undefined;
contactId?: string;
isPreview: boolean;
}
export const renderSurvey = async ({
survey,
searchParams,
singleUseId,
singleUseResponse,
contactId,
isPreview,
}: SurveyRendererProps) => {
const locale = await findMatchingLocale();
const langParam = searchParams.lang;
const isEmbed = searchParams.embed === "true";
if (survey.status === "draft" || survey.type !== "link") {
notFound();
}
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error("Organization not found");
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan);
if (survey.status !== "inProgress" && !isPreview) {
return (
<SurveyInactive
status={survey.status}
surveyClosedMessage={survey.surveyClosedMessage ? survey.surveyClosedMessage : undefined}
/>
);
}
// verify email: Check if the survey requires email verification
let emailVerificationStatus = "";
let verifiedEmail: string | undefined = undefined;
if (survey.isVerifyEmailEnabled) {
const token = searchParams.verify;
if (token) {
const emailVerificationDetails = await getEmailVerificationDetails(survey.id, token);
emailVerificationStatus = emailVerificationDetails.status;
verifiedEmail = emailVerificationDetails.email;
}
}
// get project
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Project not found");
}
const getLanguageCode = (): string => {
if (!langParam || !isMultiLanguageAllowed) return "default";
else {
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
if (!selectedLanguage || selectedLanguage?.default || !selectedLanguage?.enabled) {
return "default";
}
return selectedLanguage.language.code;
}
};
const languageCode = getLanguageCode();
const isSurveyPinProtected = Boolean(survey.pin);
const responseCount = await getResponseCountBySurveyId(survey.id);
if (isSurveyPinProtected) {
return (
<PinScreen
surveyId={survey.id}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
webAppUrl={WEBAPP_URL}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
locale={locale}
isPreview={isPreview}
contactId={contactId}
/>
);
}
return (
<LinkSurvey
survey={survey}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={singleUseId}
singleUseResponse={singleUseResponse}
webAppUrl={WEBAPP_URL}
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
contactId={contactId}
/>
);
};

View File

@@ -1,75 +0,0 @@
import { verifyContactSurveyToken } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getSurvey } from "@/modules/survey/lib/survey";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
import { getExistingContactResponse } from "@/modules/survey/link/lib/response";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
interface ContactSurveyPageProps {
params: Promise<{
jwt: string;
}>;
searchParams: Promise<{
verify?: string;
lang?: string;
embed?: string;
preview?: string;
}>;
}
export const generateMetadata = async (props: ContactSurveyPageProps): Promise<Metadata> => {
const { jwt } = await props.params;
try {
// Verify and decode the JWT token
const result = verifyContactSurveyToken(jwt);
if (!result.ok) {
return {
title: "Survey",
description: "Complete this survey",
};
}
const { surveyId } = result.data;
return getBasicSurveyMetadata(surveyId);
} catch (error) {
// If the token is invalid, we'll return generic metadata
return {
title: "Survey",
description: "Complete this survey",
};
}
};
export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const { jwt } = params;
const { preview } = searchParams;
const result = verifyContactSurveyToken(jwt);
if (!result.ok) {
return <SurveyInactive status="link invalid" />;
}
const { surveyId, contactId } = result.data;
const existingResponse = await getExistingContactResponse(surveyId, contactId);
if (existingResponse) {
return <SurveyInactive status="response submitted" />;
}
const isPreview = preview === "true";
const survey = await getSurvey(surveyId);
if (!survey) {
notFound();
}
return renderSurvey({
survey,
searchParams,
contactId,
isPreview,
});
};

View File

@@ -1,200 +0,0 @@
import { getSurvey } from "@/modules/survey/lib/survey";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TSurvey, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
import {
getBasicSurveyMetadata,
getBrandColorForURL,
getNameForURL,
getSurveyOpenGraphMetadata,
} from "./metadata-utils";
// Mock dependencies
vi.mock("@/modules/survey/lib/survey", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/link/lib/project", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
// Mock constants
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: vi.fn(() => false),
WEBAPP_URL: "https://test.formbricks.com",
}));
vi.mock("@formbricks/lib/styling/constants", () => ({
COLOR_DEFAULTS: {
brandColor: "#00c4b8",
},
}));
describe("Metadata Utils", () => {
// Reset all mocks before each test
beforeEach(() => {
vi.clearAllMocks();
});
describe("getNameForURL", () => {
it("replaces spaces with %20", () => {
const result = getNameForURL("Hello World");
expect(result).toBe("Hello%20World");
});
it("handles strings with no spaces correctly", () => {
const result = getNameForURL("HelloWorld");
expect(result).toBe("HelloWorld");
});
it("handles strings with multiple spaces", () => {
const result = getNameForURL("Hello World Test");
expect(result).toBe("Hello%20%20World%20%20Test");
});
});
describe("getBrandColorForURL", () => {
it("replaces # with %23", () => {
const result = getBrandColorForURL("#ff0000");
expect(result).toBe("%23ff0000");
});
it("handles strings with no # correctly", () => {
const result = getBrandColorForURL("ff0000");
expect(result).toBe("ff0000");
});
});
describe("getBasicSurveyMetadata", () => {
const mockSurveyId = "survey-123";
const mockEnvironmentId = "env-456";
it("returns default metadata when survey is not found", async () => {
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(result).toEqual({
title: "Survey",
description: "Complete this survey",
survey: null,
});
});
it("uses welcome card headline when available", async () => {
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
welcomeCard: {
enabled: true,
timeToFinish: false,
showResponseCount: false,
headline: {
default: "Welcome Headline",
},
html: {
default: "Welcome Description",
},
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ name: "Test Project" } as any);
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockEnvironmentId);
expect(result).toEqual({
title: "Welcome Headline | Formbricks",
description: "Welcome Description",
survey: mockSurvey,
});
});
it("falls back to survey name when welcome card is not enabled", async () => {
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
welcomeCard: {
enabled: false,
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue({ name: "Test Project" } as any);
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(result).toEqual({
title: "Test Survey | Formbricks",
description: "Complete this survey",
survey: mockSurvey,
});
});
it("adds Formbricks to title when IS_FORMBRICKS_CLOUD is true", async () => {
// Change the mock for this specific test
(IS_FORMBRICKS_CLOUD as unknown as ReturnType<typeof vi.fn>).mockReturnValue(true);
const mockSurvey = {
id: mockSurveyId,
environmentId: mockEnvironmentId,
name: "Test Survey",
welcomeCard: {
enabled: false,
} as TSurveyWelcomeCard,
} as TSurvey;
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
const result = await getBasicSurveyMetadata(mockSurveyId);
expect(result.title).toBe("Test Survey | Formbricks");
// Reset the mock
(IS_FORMBRICKS_CLOUD as unknown as ReturnType<typeof vi.fn>).mockReturnValue(false);
});
});
describe("getSurveyOpenGraphMetadata", () => {
it("generates correct OpenGraph metadata", () => {
const surveyId = "survey-123";
const surveyName = "Test Survey";
const brandColor = COLOR_DEFAULTS.brandColor.replace("#", "%23");
const encodedName = surveyName.replace(/ /g, "%20");
const result = getSurveyOpenGraphMetadata(surveyId, surveyName);
expect(result).toEqual({
metadataBase: new URL(WEBAPP_URL),
openGraph: {
title: surveyName,
description: "Thanks a lot for your time 🙏",
url: `/s/${surveyId}`,
siteName: "",
images: [`/api/v1/og?brandColor=${brandColor}&name=${encodedName}`],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: surveyName,
description: "Thanks a lot for your time 🙏",
images: [`/api/v1/og?brandColor=${brandColor}&name=${encodedName}`],
},
});
});
it("handles survey names with spaces correctly", () => {
const surveyId = "survey-123";
const surveyName = "Test Survey With Spaces";
const result = getSurveyOpenGraphMetadata(surveyId, surveyName);
expect(result.openGraph?.images?.[0]).toContain("name=Test%20Survey%20With%20Spaces");
});
});
});

View File

@@ -1,92 +0,0 @@
import { getSurvey } from "@/modules/survey/lib/survey";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { Metadata } from "next";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
/**
* Utility function to encode name for URL usage
*/
export const getNameForURL = (url: string) => url.replace(/ /g, "%20");
/**
* Utility function to encode brand color for URL usage
*/
export const getBrandColorForURL = (url: string) => url.replace(/#/g, "%23");
/**
* Get basic survey metadata (title and description) based on welcome card or survey name
*/
export const getBasicSurveyMetadata = async (surveyId: string) => {
const survey = await getSurvey(surveyId);
// If survey doesn't exist, return default metadata
if (!survey) {
return {
title: "Survey",
description: "Complete this survey",
survey: null,
};
}
const project = await getProjectByEnvironmentId(survey.environmentId);
const welcomeCard = survey.welcomeCard as TSurveyWelcomeCard;
// Set title to either welcome card headline or survey name
let title = "Survey";
if (welcomeCard.enabled && welcomeCard.headline?.default) {
title = welcomeCard.headline.default;
} else {
title = survey.name;
}
// Set description to either welcome card html content or default
let description = "Complete this survey";
if (welcomeCard.enabled && welcomeCard.html?.default) {
description = welcomeCard.html.default;
}
// Add product name in title if it's Formbricks cloud
if (IS_FORMBRICKS_CLOUD) {
title = `${title} | Formbricks`;
} else if (project) {
// Since project name is not available in the returned type, we'll just use a generic name
title = `${title} | Survey`;
}
return {
title,
description,
survey,
};
};
/**
* Generate Open Graph metadata for survey
*/
export const getSurveyOpenGraphMetadata = (surveyId: string, surveyName: string): Metadata => {
const brandColor = getBrandColorForURL(COLOR_DEFAULTS.brandColor); // Default color
const encodedName = getNameForURL(surveyName);
const ogImgURL = `/api/v1/og?brandColor=${brandColor}&name=${encodedName}`;
return {
metadataBase: new URL(WEBAPP_URL),
openGraph: {
title: surveyName,
description: "Thanks a lot for your time 🙏",
url: `/s/${surveyId}`,
siteName: "",
images: [ogImgURL],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: surveyName,
description: "Thanks a lot for your time 🙏",
images: [ogImgURL],
},
};
};

View File

@@ -69,35 +69,3 @@ export const getResponseBySingleUseId = reactCache(
}
)()
);
export const getExistingContactResponse = reactCache(
async (surveyId: string, contactId: string): Promise<Pick<Response, "id" | "finished"> | null> =>
cache(
async () => {
try {
const response = await prisma.response.findFirst({
where: {
surveyId,
contactId,
},
select: {
id: true,
finished: true,
},
});
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`link-surveys-getExisitingContactResponse-${surveyId}-${contactId}`],
{
tags: [responseCache.tag.bySurveyId(surveyId), responseCache.tag.byContactId(contactId)],
}
)()
);

View File

@@ -1,8 +1,8 @@
import { getSurveyMetadata } from "@/modules/survey/link/lib/survey";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { getBrandColorForURL, getNameForURL, getSurveyOpenGraphMetadata } from "./lib/metadata-utils";
export const getMetadataForLinkSurvey = async (surveyId: string): Promise<Metadata> => {
const survey = await getSurveyMetadata(surveyId);
@@ -13,22 +13,30 @@ export const getMetadataForLinkSurvey = async (surveyId: string): Promise<Metada
const brandColor = getBrandColorForURL(survey.styling?.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
const surveyName = getNameForURL(survey.name);
const ogImgURL = `/api/v1/og?brandColor=${brandColor}&name=${surveyName}`;
// Use the shared function for creating the base metadata but override with specific OpenGraph data
const baseMetadata = getSurveyOpenGraphMetadata(survey.id, survey.name);
// Override with the custom image URL that uses the survey's brand color
if (baseMetadata.openGraph) {
baseMetadata.openGraph.images = [ogImgURL];
}
if (baseMetadata.twitter) {
baseMetadata.twitter.images = [ogImgURL];
}
return {
title: survey.name,
...baseMetadata,
metadataBase: new URL(WEBAPP_URL),
openGraph: {
title: survey.name,
description: "Thanks a lot for your time 🙏",
url: `/s/${survey.id}`,
siteName: "",
images: [ogImgURL],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: survey.name,
description: "Thanks a lot for your time 🙏",
images: [ogImgURL],
},
};
};
const getNameForURL = (url: string) => url.replace(/ /g, "%20");
const getBrandColorForURL = (url: string) => url.replace(/#/g, "%23");

View File

@@ -1,11 +1,21 @@
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { getSurvey } from "@/modules/survey/lib/survey";
import { LinkSurvey } from "@/modules/survey/link/components/link-survey";
import { PinScreen } from "@/modules/survey/link/components/pin-screen";
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
import { getEmailVerificationDetails } from "@/modules/survey/link/lib/helper";
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
import { getResponseBySingleUseId } from "@/modules/survey/link/lib/response";
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
import { Response } from "@prisma/client";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { ZId } from "@formbricks/types/common";
interface LinkSurveyPageProps {
@@ -38,13 +48,33 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
if (!validId.success) {
notFound();
}
const isPreview = searchParams.preview === "true";
const survey = await getSurvey(params.surveyId);
const locale = await findMatchingLocale();
const suId = searchParams.suId;
const langParam = searchParams.lang; //can either be language code or alias
const isSingleUseSurvey = survey?.singleUse?.enabled;
const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted;
const isEmbed = searchParams.embed === "true";
if (!survey || survey.type !== "link" || survey.status === "draft") {
notFound();
}
const organizationId = await getOrganizationIdFromEnvironmentId(survey.environmentId);
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error("Organization not found");
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan);
if (survey.status !== "inProgress" && !isPreview) {
return (
<SurveyInactive
status={survey.status}
surveyClosedMessage={survey.surveyClosedMessage ? survey.surveyClosedMessage : undefined}
/>
);
}
let singleUseId: string | undefined = undefined;
if (isSingleUseSurvey) {
@@ -65,7 +95,7 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
singleUseId = validatedSingleUseId ?? suId;
}
let singleUseResponse;
let singleUseResponse: Pick<Response, "id" | "finished"> | undefined = undefined;
if (isSingleUseSurvey) {
try {
singleUseResponse = singleUseId
@@ -76,11 +106,85 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
}
}
return renderSurvey({
survey,
searchParams,
singleUseId,
singleUseResponse,
isPreview,
});
// verify email: Check if the survey requires email verification
let emailVerificationStatus = "";
let verifiedEmail: string | undefined = undefined;
if (survey.isVerifyEmailEnabled) {
const token = searchParams.verify;
if (token) {
const emailVerificationDetails = await getEmailVerificationDetails(survey.id, token);
emailVerificationStatus = emailVerificationDetails.status;
verifiedEmail = emailVerificationDetails.email;
}
}
// get project and person
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Project not found");
}
const getLanguageCode = (): string => {
if (!langParam || !isMultiLanguageAllowed) return "default";
else {
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});
if (selectedLanguage?.default || !selectedLanguage?.enabled) {
return "default";
}
return selectedLanguage.language.code;
}
};
const languageCode = getLanguageCode();
const isSurveyPinProtected = Boolean(survey.pin);
const responseCount = await getResponseCountBySurveyId(survey.id);
if (isSurveyPinProtected) {
return (
<PinScreen
surveyId={survey.id}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
webAppUrl={WEBAPP_URL}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
locale={locale}
isPreview={isPreview}
/>
);
}
return (
<LinkSurvey
survey={survey}
project={project}
emailVerificationStatus={emailVerificationStatus}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
webAppUrl={WEBAPP_URL}
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
verifiedEmail={verifiedEmail}
languageCode={languageCode}
isEmbed={isEmbed}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
locale={locale}
isPreview={isPreview}
/>
);
};

View File

@@ -1,7 +1,7 @@
// vitest.config.ts
import react from "@vitejs/plugin-react";
import { loadEnv } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
@@ -20,7 +20,6 @@ export default defineConfig({
reportsDirectory: "./coverage", // Output coverage reports to the coverage/ directory
include: [
"modules/api/v2/**/*.ts",
"modules/api/v2/**/*.tsx",
"modules/auth/lib/**/*.ts",
"modules/signup/lib/**/*.ts",
"modules/ee/whitelabel/email-customization/components/*.tsx",
@@ -28,14 +27,13 @@ export default defineConfig({
"modules/email/emails/survey/follow-up.tsx",
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
"modules/ee/sso/lib/**/*.ts",
"modules/ee/contacts/lib/**/*.ts",
"modules/survey/link/lib/**/*.ts",
"app/(auth)/layout.tsx",
"app/(app)/layout.tsx",
"app/intercom/*.tsx",
],
exclude: [
"**/.next/**",
"**/*.test.*",
"**/*.spec.*",
"**/constants.ts", // Exclude constants files
"**/route.ts", // Exclude route files

View File

@@ -21,41 +21,15 @@ This guide explains the settings you need to use to configure SAML with your Ide
**Assertion Encryption:** Unencrypted
**NameID Format:** EmailAddress
**Application username:** email
**Mapping Attributes / Attribute Statements:**
- Name claim:
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier) -> id
If your IdP has a `name` claim, set the following claims to populate the name field:
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress) -> email
| Name | Name Format | Value |
| ---- | ----------- | --------- |
| name | Basic | user.name |
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname) -> firstName
Many IdPs do not have a `name` claim. If not, you can use different claims to populate the name field. The order of precedence is `name` -> **other options** -> `email`.
**Other options:**
| Name | Name Format | Value |
| --------- | ----------- | ------------------------- |
| firstName | Basic | **FIRST_NAME_EQUIVALENT** |
| lastName | Basic | **LAST_NAME_EQUIVALENT** |
Refer to the table below for the different claims you can use for each IdP.
| IdP | FIRST_NAME_EQUIVALENT | LAST_NAME_EQUIVALENT |
| ----------------------------- | -------------------------------- | -------------------------------- |
| Okta | user.firstName | user.lastName |
| Microsoft Entra ID (Azure AD) | user.givenName | user.surname |
| Google Workspace | user.given_name / user.firstName | user.family_name / user.lastName |
| OneLogin | user.FirstName / user.first_name | user.LastName / user.last_name |
| Auth0 | user.given_name | user.family_name |
| JumpCloud | user.firstname | user.lastname |
Above provided claims may differ based on your configuration and the IdP you are using. Please refer to the documentation of your IdP for the correct claims.
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname) -> lastName
### SAML With Okta

View File

@@ -116,7 +116,7 @@
]
},
{
"group": "Platform Features",
"group": "Core Features",
"pages": [
{
"group": "Integrations",
@@ -141,6 +141,10 @@
"xm-and-surveys/core-features/test-environment"
]
},
{
"group": "Enterprise Features",
"pages": ["xm-and-surveys/enterprise-features/saml-sso"]
},
{
"group": "XM",
"pages": [
@@ -243,8 +247,8 @@
"pages": [
"self-hosting/setup/one-click",
"self-hosting/setup/docker",
"self-hosting/setup/monitoring",
"self-hosting/setup/cluster-setup",
"self-hosting/setup/monitoring",
"self-hosting/setup/kubernetes"
]
},

View File

@@ -4,7 +4,9 @@ description: "Configure Microsoft Entra ID (Azure AD) OAuth for secure Single Si
icon: "microsoft"
---
<Note>AzureAD OAuth is part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license)</Note>
<Note>
Single Sign-On (SSO) functionality, including OAuth integrations with Google, Microsoft Azure AD, and OpenID Connect, requires is part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>
### Microsoft Entra ID

View File

@@ -4,7 +4,9 @@ description: "Configure Google OAuth for secure Single Sign-On with your Formbri
icon: "google"
---
<Note>Google OAuth is part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license)</Note>
<Note>
Single Sign-On (SSO) functionality, including OAuth integrations with Google, Microsoft Azure AD, and OpenID Connect, requires is part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>
### Google OAuth

View File

@@ -4,7 +4,9 @@ description: "Configure Open ID Connect for secure Single Sign-On with your Form
icon: "key"
---
<Note>OpenID Connect is part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license)</Note>
<Note>
Single Sign-On (SSO) functionality, including OAuth integrations with Google, Microsoft Azure AD, and OpenID Connect, requires is part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>
Integrating your own OIDC (OpenID Connect) instance with your Formbricks instance allows users to log in using their OIDC credentials, ensuring a secure and streamlined user experience. Please follow the steps below to set up OIDC for your Formbricks instance.

View File

@@ -1,10 +1,10 @@
---
title: "SAML SSO"
title: "SAML SSO - Self-hosted"
icon: "user-shield"
description: "Configure SAML Single Sign-On (SSO) for secure enterprise authentication with your Formbricks instance."
---
<Note>SAML SSO is part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license)</Note>
<Note>You require an Enterprise License along with a SAML SSO add-on to avail this feature.</Note>
## Overview
@@ -71,7 +71,7 @@ To configure SAML SSO in Formbricks, follow these steps:
<Step title="Database Setup">
Configure a dedicated database for SAML by setting the `SAML_DATABASE_URL` environment variable in your `docker-compose.yml` file (e.g., `postgres://postgres:postgres@postgres:5432/formbricks-saml`). If you're using a self-signed certificate for Postgres, include the `sslmode=disable` parameter.
</Step>
<Step title="IdP Application">
Create a SAML application in your IdP by following your provider's instructions([SAML Setup](/development/guides/auth-and-provision/setup-saml-with-identity-providers))
</Step>
@@ -79,7 +79,7 @@ To configure SAML SSO in Formbricks, follow these steps:
<Step title="User Provisioning">
Provision users in your IdP and configure access to the IdP SAML app for all your users (who need access to Formbricks).
</Step>
<Step title="Metadata">
Keep the XML metadata from your IdP handy for the next step.
</Step>

View File

@@ -1,9 +1,14 @@
---
title: "Cluster Setup"
description: "How to set up Formbricks in a High-Availability Cluster"
description: "How to set up Formbricks in a cluster"
icon: "circle-nodes"
---
<Note>
Running Formbricks in a multi-instance cluster configuration is an Enterprise Edition feature and requires
an enterprise license key.
</Note>
## Overview
Running Formbricks as a cluster of multiple instances offers several key advantages:

View File

@@ -4,7 +4,13 @@ description: "Deploy the new Helm chart on a Kubernetes cluster using Helm."
icon: "circle-nodes"
---
## Prerequisites
# **🚀 Kubernetes Deployment Guide**
This guide explains how to deploy the **Formbricks Helm Chart** on a Kubernetes cluster using Helm. It provides configuration options for **internal** and **external** databases, caching services, and secrets management.
---
## **📌 Prerequisites**
Ensure you have the following before proceeding:
- A running Kubernetes cluster (EKS, GKE, AKS, Minikube, etc.)
@@ -16,37 +22,32 @@ Ensure you have the following before proceeding:
---
## 1. Installation Steps
## **1 Installation Steps**
<Steps>
<Step title="Clone the Helm Chart">
### **🔹 Step 1: Clone the Helm Chart**
```sh
git clone https://github.com/formbricks/formbricks
cd helm-chart
```
</Step>
<Step title="Install with Default Configuration">
### **🔹 Step 2: Install with Default Configuration**
```sh
helm install formbricks ./ -n formbricks --create-namespace
```
By default:
- PostgreSQL and Redis are deployed within the cluster.
- Secrets are dynamically generated and stored as Kubernetes Secrets.
</Step>
- PostgreSQL and Redis **are deployed within the cluster**.
- Secrets **are dynamically generated** and stored as Kubernetes Secrets.
<Step title="Install with an Enterprise License">
### **🔹 Step 3: Install with an Enterprise License**
```sh
helm install formbricks ./ -n formbricks --create-namespace --set enterprise.licenseKey="YOUR_LICENSE_KEY"
```
</Step>
</Steps>
---
## 2. Configuring Secrets
## **2 Configuring Secrets**
### Using Kubernetes Secrets (Default)
### **🔹 Using Kubernetes Secrets (Default)**
By default, **secrets are stored as Kubernetes Secrets**.
The chart automatically generates **random values** for required secrets.
@@ -58,7 +59,7 @@ secret:
---
### Using External Secrets (AWS Secrets Manager, Vault, etc.)
### **🔹 Using External Secrets (AWS Secrets Manager, Vault, etc.)**
To use an **external secrets manager**, enable `externalSecret` in `values.yaml`:
```yaml
secret:
@@ -102,7 +103,7 @@ externalSecret:
key: "prod/formbricks/secrets"
property: ENCRYPTION_KEY
```
**Ensure ExternalSecrets Operator is installed:**
📌 **Ensure ExternalSecrets Operator is installed:**
[https://external-secrets.io/latest/](https://external-secrets.io/latest/)
Install with:
@@ -112,9 +113,9 @@ helm install formbricks ./ -n formbricks --create-namespace -f values.yaml
---
## 3. Configuring PostgreSQL and Redis
## **3 Configuring PostgreSQL and Redis**
### Using Managed PostgreSQL and Redis
### **🔹 Using Managed PostgreSQL and Redis**
For production, we recommend using **managed database and cache services**.
Modify `values.yaml`:
@@ -134,7 +135,7 @@ helm install formbricks ./ -n formbricks --create-namespace -f values.yaml
---
### Using In-Cluster PostgreSQL and Redis (Default)
### **🔹 Using In-Cluster PostgreSQL and Redis (Default)**
By default, PostgreSQL and Redis are **deployed inside the cluster**.
To **ensure in-cluster deployment**, use:
@@ -152,25 +153,25 @@ helm install formbricks ./ -n formbricks --create-namespace -f values.yaml
---
## 4. Upgrading the Deployment
## **4 Upgrading the Deployment**
To apply changes:
```sh
helm upgrade formbricks ./ -n formbricks
```
### Scaling Resources
### **🔹 Scaling Resources**
```sh
helm upgrade formbricks ./ -n formbricks --set deployment.resources.limits.cpu=2 --set deployment.resources.limits.memory=4Gi
```
### Enabling Autoscaling
### **🔹 Enabling Autoscaling**
```sh
helm upgrade formbricks ./ -n formbricks --set autoscaling.enabled=true --set autoscaling.minReplicas=3 --set autoscaling.maxReplicas=10
```
---
## 5. Key Configuration Values
## **5 Key Configuration Values**
| Field | Description | Default Value |
|--------------------------------|--------------------------------------|--------------|
@@ -184,17 +185,17 @@ helm upgrade formbricks ./ -n formbricks --set autoscaling.enabled=true --se
| `redis.externalRedisUrl` | External Redis URL | `""` |
| `externalSecret.enabled` | Enable external secrets manager | `false` |
**Refer to the Helm chart repository for full configuration options.**
📌 **Refer to the Helm chart repository for full configuration options.**
---
## 6. Uninstalling the Deployment
## **6 Uninstalling the Deployment**
To remove the deployment:
```sh
helm uninstall formbricks -n formbricks
```
### Removing Persistent Volumes (PVCs)
### **Removing Persistent Volumes (PVCs)**
By default, **PVCs are not deleted** with Helm.
To manually remove them:
```sh
@@ -208,9 +209,9 @@ kubectl delete namespace formbricks
---
## Additional Notes
## **📢 Additional Notes**
- **Ingress Setup:** If using an ingress controller, make sure to configure `ingress.enabled: true` in `values.yaml`.
- **Environment Variables:** Pass custom environment variables via `envFrom` in `values.yaml`.
- **Backup Strategy:** Ensure you have a backup policy for PostgreSQL if running in-cluster.
**Your Formbricks deployment is now ready!**
🚀 **Your Formbricks deployment is now ready!** 🚀

View File

@@ -1,19 +1,17 @@
---
title: "Email Branding"
description: "Branding the emails that are sent to your respondents."
title: "Email Customization"
description: "Customize the email that is sent to your users!"
icon: "envelope"
---
Email branding is a white-label feature that allows you to customize the email that is sent to your users. You can upload a logo of your company and use it in the email.
Email customization is a white-label feature that allows you to customize the email that is sent to your users. You can upload a logo of your company and use it in the email.
<Note>
Email branding is part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license).
This feature is a white-label feature. It is only available for users on paid
plans or have an enterprise license.
</Note>
<Info>
Only the Owner and Managers of the organization can modify the logo.
</Info>
## How to upload a logo
## How to Upload a Logo
1. Go to the Organization Settings page.
2. You will see a card called **Email Customization** under the **General** section.
@@ -31,6 +29,11 @@ You can click on the **Send test email** button to get a test email with the log
![Email Sample](/images/xm-and-surveys/core-features/email-customization/email-sample.webp)
<Note>
Only the owner and managers of the organization can modify the logo.
</Note>
## Use Cases
- **White-labeling**: You can use this feature to white-label your emails to your users.

View File

@@ -13,7 +13,7 @@ Learn about the different organization-level and team-level roles and how they a
Permissions in Formbricks are broadly handled using organization-level roles, which apply to all teams and projects in the organization. Users on a self-hosting and Enterprise plan, have access to team-level roles, which enable more granular permissions.
<Note>
Access Roles is a feature of the [Enterprise Edition](/self-hosting/advanced/license). In the **Community Edition** and on the **Free**
Access Roles is a feature of the **Enterprise Edition**. In the **Community Edition** and on the **Free**
and **Startup** plan in the Cloud you can invite unlimited organization members as `Owner`.
</Note>

View File

@@ -0,0 +1,43 @@
---
title: "SAML SSO"
icon: "user-shield"
description: "How to set up SAML SSO for Formbricks"
---
<Note>This feature is only available with the Formbricks Enterprise plan having a SAML SSO add-on.</Note>
## Overview
Formbricks supports Security Assertion Markup Language (SAML) SSO. We prioritize your ease of access and security by providing robust Single Sign-On (SSO) capabilities.
### Setting up SAML login
<Steps>
<Step title="Create a SAML application with your Identity Provider (IdP)">
Follow the instructions here - [SAML
Setup](/development/guides/auth-and-provision/setup-saml-with-identity-providers)
</Step>
<Step title="Configure access to the IdP SAML app">
Ensure that all users who need access to Formbricks have access to the IdP SAML app.
</Step>
<Step title="Retrieve XML metadata from your IdP">
Keep the XML metadata from your IdP accessible, as you will need it later.
</Step>
<Step title="Set the SAML_DATABASE_URL environment variable">
Set the `SAML_DATABASE_URL` environment variable in your `.env` file to a dedicated database for
SAML(e.g., `postgresql://postgres:@localhost:5432/formbricks-saml`). If you're using a self-signed
certificate for Postgres, include the `sslmode=disable` parameter.
</Step>
<Step title="Set the metadata">
Create a file called `connection.xml` in the `apps/web/saml-connection` directory and paste the XML
metadata from your IdP into it. Please create the directory if it doesn't exist. Your metadata file should start with a tag like this: `<?xml version="1.0" encoding="UTF-8"?><...>` or `<md:EntityDescriptor entityID="...">`. Please remove any extra text from the metadata.
</Step>
<Step title="Your users can now log into Formbricks using SAML">
Once setup is complete, please restart the Formbricks server and your users can log into Formbricks using SAML.
</Step>
</Steps>
<Note>
We don't support multiple SAML connections yet. You can only have one SAML connection at a time. If you
change the `connection.xml` file, your existing SAML connection will be overwritten.
</Note>

View File

@@ -9,7 +9,7 @@ icon: "envelope"
The email followup feature allows survey creators to automatically send customized emails to respondents based on their survey responses or when they reach specific survey endings. This feature is particularly useful for following up with respondents, sending thank you notes, or providing additional information.
<Note>
Email followups is a paid feature. It is only available for users on paid plans or if you have [Enterprise Edition](/self-hosting/advanced/license).
Email followups is a paid feature. It is only available for users on paid plans or have an enterprise license.
</Note>
## Key Components

View File

@@ -4,8 +4,6 @@ description: "Create surveys that support multiple languages using translations.
icon: "language"
---
<Note>Multi-language surveys are part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license)</Note>
How to deliver a specific language depends on the survey type (app or link survey):
- App & Website survey: Set a `language` attribute for the user. [Read this guide for App Surveys](#app-surveys-configuration)

View File

@@ -4,9 +4,9 @@ description: "Advanced Targeting allows you to show surveys to a specific segmen
icon: "bullseye"
---
<Note>
In self-hosting instances advanced Targeting is part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>
<Info>
Advanced Targeting is available on paid plans for both Formbricks Cloud and On Premise.
</Info>
### When to use Advanced Targeting?

View File

@@ -4,10 +4,6 @@ description: "User Identification helps you to not only segment your users but a
icon: "user"
---
<Note>
User identification is part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license).
</Note>
### Understanding Identified vs Unidentified Users
In Formbricks, understanding the difference between identified and unidentified users is crucial for effective survey segmentation and targeted feedback collection.

View File

@@ -18,7 +18,3 @@ data "aws_iam_roles" "administrator" {
data "aws_iam_roles" "github" {
name_regex = "formbricks-prod-github"
}
data "aws_acm_certificate" "formbricks" {
domain = local.domain
}

View File

@@ -32,6 +32,22 @@ module "route53_zones" {
}
}
module "acm" {
source = "terraform-aws-modules/acm/aws"
version = "5.1.1"
domain_name = local.domain
zone_id = module.route53_zones.route53_zone_zone_id[local.domain]
subject_alternative_names = [
"*.${local.domain}",
]
validation_method = "DNS"
tags = local.tags
}
################################################################################
# VPC
################################################################################
@@ -124,7 +140,6 @@ module "eks" {
cluster_addons = {
coredns = {
most_recent = true
}
eks-pod-identity-agent = {
most_recent = true
@@ -442,15 +457,14 @@ module "iam_policy" {
]
Resource = [
module.s3-bucket.s3_bucket_arn,
"${module.s3-bucket.s3_bucket_arn}/*",
"arn:aws:s3:::formbricks-cloud-uploads",
"arn:aws:s3:::formbricks-cloud-uploads/*"
"${module.s3-bucket.s3_bucket_arn}/*"
]
}
]
})
}
module "formkey-aws-access" {
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "5.53.0"
@@ -474,9 +488,7 @@ module "formkey-aws-access" {
resource "helm_release" "formbricks" {
name = "formbricks"
namespace = "formbricks"
repository = "oci://ghcr.io/formbricks/helm-charts"
chart = "formbricks"
version = "3.5.0"
chart = "${path.module}/../../helm-chart"
max_history = 5
values = [
@@ -499,7 +511,7 @@ resource "helm_release" "formbricks" {
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
alb.ingress.kubernetes.io/ssl-redirect: "443"
alb.ingress.kubernetes.io/certificate-arn: ${data.aws_acm_certificate.formbricks.arn}
alb.ingress.kubernetes.io/certificate-arn: ${module.acm.acm_certificate_arn}
alb.ingress.kubernetes.io/healthcheck-path: "/health"
alb.ingress.kubernetes.io/group.name: formbricks
alb.ingress.kubernetes.io/ssl-policy: "ELBSecurityPolicy-TLS13-1-2-2021-06"
@@ -516,11 +528,13 @@ resource "helm_release" "formbricks" {
enabled: true
reloadOnChange: true
deployment:
nodeSelector:
karpenter.sh/capacity-type: "on-demand"
image:
repository: "ghcr.io/formbricks/formbricks-experimental"
tag: "open-telemetry-for-prometheus"
pullPolicy: Always
env:
S3_BUCKET_NAME:
value: "formbricks-20250311043609595200000002"
value: ${module.s3-bucket.s3_bucket_id}
RATE_LIMITING_DISABLED:
value: "1"
envFrom:

View File

@@ -48,89 +48,7 @@ module "observability_loki_iam_role" {
oidc_providers = {
eks = {
provider_arn = module.eks.oidc_provider_arn
namespace_service_accounts = ["monitoring:loki"]
}
}
}
module "observability_grafana_iam_policy" {
source = "terraform-aws-modules/iam/aws//modules/iam-policy"
version = "5.53.0"
name_prefix = "grafana-"
path = "/"
description = "Policy for Formbricks observability apps - Grafana"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowReadingMetricsFromCloudWatch"
Effect = "Allow"
Action = [
"cloudwatch:DescribeAlarmsForMetric",
"cloudwatch:DescribeAlarmHistory",
"cloudwatch:DescribeAlarms",
"cloudwatch:ListMetrics",
"cloudwatch:GetMetricData",
"cloudwatch:GetInsightRuleReport"
]
Resource = "*"
},
{
Sid = "AllowReadingResourceMetricsFromPerformanceInsights"
Effect = "Allow"
Action = "pi:GetResourceMetrics"
Resource = "*"
},
{
Sid = "AllowReadingLogsFromCloudWatch"
Effect = "Allow"
Action = [
"logs:DescribeLogGroups",
"logs:GetLogGroupFields",
"logs:StartQuery",
"logs:StopQuery",
"logs:GetQueryResults",
"logs:GetLogEvents"
]
Resource = "*"
},
{
Sid = "AllowReadingTagsInstancesRegionsFromEC2"
Effect = "Allow"
Action = [
"ec2:DescribeTags",
"ec2:DescribeInstances",
"ec2:DescribeRegions"
]
Resource = "*"
},
{
Sid = "AllowReadingResourcesForTags"
Effect = "Allow"
Action = "tag:GetResources"
Resource = "*"
}
]
})
}
module "observability_grafana_iam_role" {
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "5.53.0"
role_name_prefix = "grafana-"
role_policy_arns = {
"formbricks" = module.observability_grafana_iam_policy.arn
}
assume_role_condition_test = "StringLike"
oidc_providers = {
eks = {
provider_arn = module.eks.oidc_provider_arn
namespace_service_accounts = ["monitoring:grafana"]
namespace_service_accounts = ["monitoring:loki*"]
}
}
}

View File

@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
kotlin("kapt")
}
@@ -35,6 +36,7 @@ android {
jvmTarget = "11"
}
buildFeatures {
compose = true
dataBinding = true
}
}
@@ -43,6 +45,12 @@ dependencies {
implementation(project(":formbricksSDK"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
@@ -51,4 +59,8 @@ dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}

View File

@@ -2,5 +2,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.android.library) apply false
}

View File

@@ -1,9 +1,13 @@
package com.formbricks.formbrickssdk.model.environment
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@JsonIgnoreUnknownKeys
data class Segment(
@SerializedName("id") val id: String? = null,
@SerializedName("createdAt") val createdAt: String? = null,

View File

@@ -1,9 +1,13 @@
package com.formbricks.formbrickssdk.model.environment
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@JsonIgnoreUnknownKeys
data class Styling(
@SerializedName("roundness") val roundness: Double? = null,
@SerializedName("allowStyleOverwrite") val allowStyleOverwrite: Boolean? = null,

View File

@@ -1,9 +1,13 @@
package com.formbricks.formbrickssdk.model.environment
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@JsonIgnoreUnknownKeys
data class Trigger(
@SerializedName("actionClass") val actionClass: ActionClassReference?
)

View File

@@ -8,5 +8,4 @@ enum class EventType {
@SerializedName("onDisplayCreated") ON_DISPLAY_CREATED,
@SerializedName("onResponseCreated") ON_RESPONSE_CREATED,
@SerializedName("onFilePick") ON_FILE_PICK,
@SerializedName("onSurveyLibraryLoadError") ON_SURVEY_LIBRARY_LOAD_ERROR
}

View File

@@ -75,9 +75,6 @@ class FormbricksFragment : BottomSheetDialogFragment() {
resultLauncher.launch(intent)
}
override fun onSurveyLibraryLoadError() {
dismiss()
}
})
var resultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
@@ -159,9 +156,6 @@ class FormbricksFragment : BottomSheetDialogFragment() {
it.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
consoleMessage?.let { cm ->
if (cm.messageLevel() == ConsoleMessage.MessageLevel.ERROR) {
dismiss()
}
val log = "[CONSOLE:${cm.messageLevel()}] \"${cm.message()}\", source: ${cm.sourceId()} (${cm.lineNumber()})"
Logger.d(log)
}

View File

@@ -110,7 +110,6 @@ class FormbricksViewModel : ViewModel() {
script.async = true;
script.onload = () => loadSurvey();
script.onerror = (error) => {
FormbricksJavascript.message(JSON.stringify({ event: "onSurveyLibraryLoadError" }));
console.error("Failed to load Formbricks Surveys library:", error);
};
document.head.appendChild(script);

View File

@@ -15,7 +15,6 @@ class WebAppInterface(private val callback: WebAppCallback?) {
fun onDisplayCreated()
fun onResponseCreated()
fun onFilePick(data: FileUploadData)
fun onSurveyLibraryLoadError()
}
/**
@@ -33,7 +32,6 @@ class WebAppInterface(private val callback: WebAppCallback?) {
EventType.ON_DISPLAY_CREATED -> callback?.onDisplayCreated()
EventType.ON_RESPONSE_CREATED -> callback?.onResponseCreated()
EventType.ON_FILE_PICK -> { callback?.onFilePick(FileUploadData.from(data)) }
EventType.ON_SURVEY_LIBRARY_LOAD_ERROR -> { callback?.onSurveyLibraryLoadError() }
}
} catch (e: Exception) {
Logger.e(e.message)

View File

@@ -9,6 +9,8 @@ junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
activityCompose = "1.8.0"
composeBom = "2024.04.01"
appcompat = "1.6.1"
material = "1.10.0"
@@ -33,6 +35,15 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
@@ -55,5 +66,6 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" }

View File

@@ -4,5 +4,4 @@ enum EventType: String, Codable {
case onDisplayCreated = "onDisplayCreated"
case onResponseCreated = "onResponseCreated"
case onOpenExternalURL = "onOpenExternalURL"
case onSurveyLibraryLoadError = "onSurveyLibraryLoadError"
}

View File

@@ -74,7 +74,6 @@ private extension FormbricksViewModel {
script.async = true;
script.onload = () => loadSurvey();
script.onerror = (error) => {
window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onSurveyLibraryLoadError" }));
console.error("Failed to load Formbricks Surveys library:", error);
};
document.head.appendChild(script);

View File

@@ -99,10 +99,6 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
if let message = try? JSONDecoder().decode(OpenExternalUrlMessage.self, from: data), let url = URL(string: message.onOpenExternalURLParams.url) {
UIApplication.shared.open(url)
}
/// Happens when the survey library fails to load.
case .onSurveyLibraryLoadError:
SurveyManager.shared.dismissSurveyWebView()
}
} else {
@@ -144,8 +140,7 @@ private extension SurveyWebView {
console.debug = function() { log("📘", "debug", arguments); originalDebug.apply(null, arguments) }
window.addEventListener("error", function(e) {
window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onSurveyLibraryLoadError" }));
log("💥", "Uncaught", [`${e.message} at ${e.filename}:${e.lineno}:${e.colno}`])
log("💥", "Uncaught", [`${e.message} at ${e.filename}:${e.lineno}:${e.colno}`])
})
"""
}

View File

@@ -237,7 +237,6 @@
"maximum": "Maximal",
"member": "Mitglied",
"members": "Mitglieder",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
"mobile_overlay_text": "Formbricks ist für Geräte mit kleineren Auflösungen nicht verfügbar.",
@@ -805,6 +804,7 @@
"formbricks_sdk_connected": "Formbricks SDK ist verbunden",
"formbricks_sdk_not_connected": "Formbricks SDK ist noch nicht verbunden.",
"formbricks_sdk_not_connected_description": "Verbinde deine Website oder App mit Formbricks",
"function": "Funktion",
"have_a_problem": "Hast Du ein Problem?",
"how_to_setup": "Wie einrichten",
"how_to_setup_description": "Befolge diese Schritte, um das Formbricks Widget in deiner App einzurichten.",
@@ -824,8 +824,10 @@
"step_3": "Schritt 3: Debug-Modus",
"switch_on_the_debug_mode_by_appending": "Schalte den Debug-Modus ein, indem Du anhängst",
"tag_of_your_app": "Tag deiner App",
"to_the": "zur",
"to_the_url_where_you_load_the": "URL, wo Du die lädst",
"want_to_learn_how_to_add_user_attributes": "Willst Du lernen, wie man Attribute hinzufügt?",
"you_also_need_to_pass_a": "du musst auch eine bestehen",
"you_are_done": "Du bist fertig \uD83C\uDF89",
"you_can_set_the_user_id_with": "du kannst die Benutzer-ID festlegen mit",
"your_app_now_communicates_with_formbricks": "Deine App kommuniziert jetzt mit Formbricks - sie sendet Ereignisse und lädt Umfragen automatisch!"
@@ -1898,7 +1900,6 @@
"preview_survey_questions": "Vorschau der Fragen.",
"question_preview": "Vorschau der Frage",
"response_already_received": "Wir haben bereits eine Antwort für diese E-Mail-Adresse erhalten.",
"response_submitted": "Eine Antwort, die mit dieser Umfrage und diesem Kontakt verknüpft ist, existiert bereits",
"survey_already_answered_heading": "Die Umfrage wurde bereits beantwortet.",
"survey_already_answered_subheading": "Du kannst diesen Link nur einmal verwenden.",
"survey_sent_to": "Umfrage an {email} gesendet",

View File

@@ -237,7 +237,6 @@
"maximum": "Maximum",
"member": "Member",
"members": "Members",
"membership_not_found": "Membership not found",
"metadata": "Metadata",
"minimum": "Minimum",
"mobile_overlay_text": "Formbricks is not available for devices with smaller resolutions.",
@@ -805,6 +804,7 @@
"formbricks_sdk_connected": "Formbricks SDK is connected",
"formbricks_sdk_not_connected": "Formbricks SDK is not yet connected.",
"formbricks_sdk_not_connected_description": "Connect your website or app with Formbricks",
"function": "function",
"have_a_problem": "Have a problem?",
"how_to_setup": "How to setup",
"how_to_setup_description": "Follow these steps to setup the Formbricks widget within your app.",
@@ -824,8 +824,10 @@
"step_3": "Step 3: Debug mode",
"switch_on_the_debug_mode_by_appending": "Switch on the debug mode by appending",
"tag_of_your_app": "tag of your app",
"to_the": "to the",
"to_the_url_where_you_load_the": "to the URL where you load the",
"want_to_learn_how_to_add_user_attributes": "Want to learn how to add user attributes, custom events and more?",
"you_also_need_to_pass_a": "you also need to pass a",
"you_are_done": "You're done \uD83C\uDF89",
"you_can_set_the_user_id_with": "you can set the user id with",
"your_app_now_communicates_with_formbricks": "Your app now communicates with Formbricks - sending events, and loading surveys automatically!"
@@ -1898,7 +1900,6 @@
"preview_survey_questions": "Preview survey questions.",
"question_preview": "Question Preview",
"response_already_received": "We already received a response for this email address.",
"response_submitted": "A response linked to this survey and contact already exists",
"survey_already_answered_heading": "The survey has already been answered.",
"survey_already_answered_subheading": "You can only use this link once.",
"survey_sent_to": "Survey sent to {email}",

View File

@@ -237,7 +237,6 @@
"maximum": "Max",
"member": "Membre",
"members": "Membres",
"membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées",
"minimum": "Min",
"mobile_overlay_text": "Formbricks n'est pas disponible pour les appareils avec des résolutions plus petites.",
@@ -805,6 +804,7 @@
"formbricks_sdk_connected": "Le SDK Formbricks est connecté",
"formbricks_sdk_not_connected": "Le SDK Formbricks n'est pas encore connecté.",
"formbricks_sdk_not_connected_description": "Connectez votre site web ou votre application à Formbricks.",
"function": "fonction",
"have_a_problem": "Vous avez un problème ?",
"how_to_setup": "Comment configurer",
"how_to_setup_description": "Suivez ces étapes pour configurer le widget Formbricks dans votre application.",
@@ -824,8 +824,10 @@
"step_3": "Étape 3 : Mode débogage",
"switch_on_the_debug_mode_by_appending": "Activez le mode débogage en ajoutant",
"tag_of_your_app": "étiquette de votre application",
"to_the": "au",
"to_the_url_where_you_load_the": "vers l'URL où vous chargez le",
"want_to_learn_how_to_add_user_attributes": "Vous voulez apprendre à ajouter des attributs utilisateur, des événements personnalisés et plus encore ?",
"you_also_need_to_pass_a": "vous devez également passer un",
"you_are_done": "Vous avez terminé \uD83C\uDF89",
"you_can_set_the_user_id_with": "vous pouvez définir l'ID utilisateur avec",
"your_app_now_communicates_with_formbricks": "Votre application communique désormais avec Formbricks - envoyant des événements et chargeant des enquêtes automatiquement !"
@@ -1898,7 +1900,6 @@
"preview_survey_questions": "Aperçu des questions de l'enquête.",
"question_preview": "Aperçu de la question",
"response_already_received": "Nous avons déjà reçu une réponse pour cette adresse e-mail.",
"response_submitted": "Une réponse liée à cette enquête et à ce contact existe déjà",
"survey_already_answered_heading": "L'enquête a déjà été répondue.",
"survey_already_answered_subheading": "Vous ne pouvez utiliser ce lien qu'une seule fois.",
"survey_sent_to": "Enquête envoyée à {email}",

View File

@@ -237,7 +237,6 @@
"maximum": "Máximo",
"member": "Membros",
"members": "Membros",
"membership_not_found": "Assinatura não encontrada",
"metadata": "metadados",
"minimum": "Mínimo",
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
@@ -805,6 +804,7 @@
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
"formbricks_sdk_not_connected": "O SDK do Formbricks ainda não está conectado.",
"formbricks_sdk_not_connected_description": "Conecte seu site ou app com o Formbricks",
"function": "função",
"have_a_problem": "Tá com problema?",
"how_to_setup": "Como configurar",
"how_to_setup_description": "Siga esses passos para configurar o widget do Formbricks no seu app.",
@@ -824,8 +824,10 @@
"step_3": "Passo 3: Modo de depuração",
"switch_on_the_debug_mode_by_appending": "Ative o modo de depuração adicionando",
"tag_of_your_app": "etiqueta do seu app",
"to_the": "pro",
"to_the_url_where_you_load_the": "para a URL onde você carrega o",
"want_to_learn_how_to_add_user_attributes": "Quer aprender como adicionar atributos de usuário, eventos personalizados e mais?",
"you_also_need_to_pass_a": "você também precisa passar um",
"you_are_done": "Você terminou \uD83C\uDF89",
"you_can_set_the_user_id_with": "você pode definir o id do usuário com",
"your_app_now_communicates_with_formbricks": "Seu app agora se comunica com o Formbricks - enviando eventos e carregando pesquisas automaticamente!"
@@ -1898,7 +1900,6 @@
"preview_survey_questions": "Visualizar perguntas da pesquisa.",
"question_preview": "Prévia da Pergunta",
"response_already_received": "Já recebemos uma resposta para este endereço de email.",
"response_submitted": "Já existe uma resposta vinculada a esta pesquisa e contato",
"survey_already_answered_heading": "A pesquisa já foi respondida.",
"survey_already_answered_subheading": "Você só pode usar esse link uma vez.",
"survey_sent_to": "Pesquisa enviada para {email}",

View File

@@ -237,7 +237,6 @@
"maximum": "Máximo",
"member": "Membro",
"members": "Membros",
"membership_not_found": "Associação não encontrada",
"metadata": "Metadados",
"minimum": "Mínimo",
"mobile_overlay_text": "O Formbricks não está disponível para dispositivos com resoluções menores.",
@@ -805,6 +804,7 @@
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
"formbricks_sdk_not_connected": "O SDK do Formbricks ainda não está conectado",
"formbricks_sdk_not_connected_description": "Ligue o seu website ou aplicação ao Formbricks",
"function": "função",
"have_a_problem": "Tem um problema?",
"how_to_setup": "Como configurar",
"how_to_setup_description": "Siga estes passos para configurar o widget Formbricks na sua aplicação.",
@@ -824,8 +824,10 @@
"step_3": "Passo 3: Modo de depuração",
"switch_on_the_debug_mode_by_appending": "Ativar o modo de depuração adicionando",
"tag_of_your_app": "tag da sua aplicação",
"to_the": "para o",
"to_the_url_where_you_load_the": "para o URL onde carrega o",
"want_to_learn_how_to_add_user_attributes": "Quer aprender a adicionar atributos de utilizador, eventos personalizados e mais?",
"you_also_need_to_pass_a": "também precisa passar um",
"you_are_done": "Está concluído \uD83C\uDF89",
"you_can_set_the_user_id_with": "pode definir o ID do utilizador com",
"your_app_now_communicates_with_formbricks": "A sua aplicação agora comunica com o Formbricks - enviando eventos e carregando inquéritos automaticamente!"
@@ -1898,7 +1900,6 @@
"preview_survey_questions": "Pré-visualizar perguntas do inquérito.",
"question_preview": "Pré-visualização da Pergunta",
"response_already_received": "Já recebemos uma resposta para este endereço de email.",
"response_submitted": "Já existe uma resposta associada a este inquérito e contacto",
"survey_already_answered_heading": "O inquérito já foi respondido.",
"survey_already_answered_subheading": "Só pode usar este link uma vez.",
"survey_sent_to": "Inquérito enviado para {email}",

View File

@@ -237,7 +237,6 @@
"maximum": "最大值",
"member": "成員",
"members": "成員",
"membership_not_found": "找不到成員資格",
"metadata": "元數據",
"minimum": "最小值",
"mobile_overlay_text": "Formbricks 不適用於較小解析度的裝置。",
@@ -805,6 +804,7 @@
"formbricks_sdk_connected": "Formbricks SDK 已連線",
"formbricks_sdk_not_connected": "Formbricks SDK 尚未連線。",
"formbricks_sdk_not_connected_description": "將您的網站或應用程式與 Formbricks 連線",
"function": "函式",
"have_a_problem": "有問題嗎?",
"how_to_setup": "如何設定",
"how_to_setup_description": "請按照這些步驟在您的應用程式中設定 Formbricks 小工具。",
@@ -824,8 +824,10 @@
"step_3": "步驟 3偵錯模式",
"switch_on_the_debug_mode_by_appending": "藉由附加以下項目開啟偵錯模式",
"tag_of_your_app": "您應用程式的標籤",
"to_the": "到",
"to_the_url_where_you_load_the": "到您載入",
"want_to_learn_how_to_add_user_attributes": "想瞭解如何新增使用者屬性、自訂事件等嗎?",
"you_also_need_to_pass_a": "您還需要傳遞",
"you_are_done": "您已完成 \uD83C\uDF89",
"you_can_set_the_user_id_with": "您可以使用 user id 設定",
"your_app_now_communicates_with_formbricks": "您的應用程式現在可與 Formbricks 通訊 - 自動傳送事件和載入問卷!"
@@ -1898,7 +1900,6 @@
"preview_survey_questions": "預覽問卷問題。",
"question_preview": "問題預覽",
"response_already_received": "我們已收到此電子郵件地址的回應。",
"response_submitted": "與此問卷和聯絡人相關的回應已經存在",
"survey_already_answered_heading": "問卷已回答。",
"survey_already_answered_subheading": "您只能使用此連結一次。",
"survey_sent_to": "問卷已發送至 '{'email'}'",

View File

@@ -1,4 +1,4 @@
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "../i18n/utils";
import { structuredClone } from "../pollyfills/structuredClone";
@@ -215,51 +215,62 @@ export const parseRecallInfo = (
) => {
let modifiedText = text;
const questionIds = responseData ? Object.keys(responseData) : [];
const variableIds = variables ? Object.keys(variables) : [];
// Process all recall patterns regardless of whether we have matching data
while (modifiedText.includes("#recall:")) {
const recallInfo = extractRecallInfo(modifiedText);
if (!recallInfo) break; // Exit the loop if no recall info is found
const variableIds = Object.keys(variables || {});
const recallItemId = extractId(recallInfo);
if (!recallItemId) {
// If no ID could be extracted, just remove the recall tag
modifiedText = modifiedText.replace(recallInfo, "");
continue;
}
if (variables && variableIds.length > 0) {
variableIds.forEach((variableId) => {
const recallPattern = `#recall:`;
while (modifiedText.includes(recallPattern)) {
const recallInfo = extractRecallInfo(modifiedText, variableId);
if (!recallInfo) break; // Exit the loop if no recall info is found
const fallback = extractFallbackValue(recallInfo).replaceAll("nbsp", " ");
let value: TResponseDataValue | undefined;
const recallItemId = extractId(recallInfo);
if (!recallItemId) continue; // Skip to the next iteration if no ID could be extracted
// First check if it matches a variable
if (variables && variableIds.includes(recallItemId)) {
value = variables[recallItemId];
}
// Then check if it matches response data
else if (responseData && questionIds.includes(recallItemId)) {
value = responseData[recallItemId];
const fallback = extractFallbackValue(recallInfo).replaceAll("nbsp", " ");
// Apply formatting for special value types
if (value) {
if (isValidDateString(value as string)) {
value = formatDateWithOrdinal(new Date(value as string));
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", ");
let value = variables[variableId] || fallback;
value = value.toString();
if (withSlash) {
modifiedText = modifiedText.replace(recallInfo, "#/" + value + "\\#");
} else {
modifiedText = modifiedText.replace(recallInfo, value);
}
}
}
});
}
// If no value was found, use the fallback
if (value === undefined || value === null || value === "") {
value = fallback;
}
if (responseData && questionIds.length > 0) {
while (modifiedText.includes("recall:")) {
const recallInfo = extractRecallInfo(modifiedText);
if (!recallInfo) break; // Exit the loop if no recall info is found
// Replace the recall tag with the value
if (withSlash) {
modifiedText = modifiedText.replace(recallInfo, "#/" + value + "\\#");
} else {
modifiedText = modifiedText.replace(recallInfo, value as string);
const recallItemId = extractId(recallInfo);
if (!recallItemId) return modifiedText; // Return the text if no ID could be extracted
const fallback = extractFallbackValue(recallInfo).replaceAll("nbsp", " ");
let value;
// Fetching value from responseData based on recallItemId
if (responseData[recallItemId]) {
value = (responseData[recallItemId] as string) ?? fallback;
}
// Additional value formatting if it exists
if (value) {
if (isValidDateString(value)) {
value = formatDateWithOrdinal(new Date(value));
} else if (Array.isArray(value)) {
value = value.filter((item) => item).join(", "); // Filters out empty values and joins with a comma
}
}
if (withSlash) {
modifiedText = modifiedText.replace(recallInfo, "#/" + (value ?? fallback) + "\\#");
} else {
modifiedText = modifiedText.replace(recallInfo, value ?? fallback);
}
}
}