mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-31 10:50:35 -06:00
Compare commits
4 Commits
v3.5.1
...
chore/aws-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
769ed48a86 | ||
|
|
d14262f804 | ||
|
|
864ad8ac45 | ||
|
|
f7a9f86693 |
@@ -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}
|
||||
|
||||
@@ -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("./");
|
||||
}
|
||||
|
||||
@@ -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("./");
|
||||
}
|
||||
|
||||
@@ -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("./");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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("./");
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route";
|
||||
|
||||
export { GET };
|
||||
@@ -1,4 +0,0 @@
|
||||
import { ContactSurveyPage, generateMetadata } from "@/modules/survey/link/contact-survey/page";
|
||||
|
||||
export { generateMetadata };
|
||||
export default ContactSurveyPage;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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 } });
|
||||
},
|
||||
});
|
||||
@@ -3,9 +3,3 @@ export type TOidcNameFields = {
|
||||
family_name?: string;
|
||||
preferred_username?: string;
|
||||
};
|
||||
|
||||
export type TSamlNameFields = {
|
||||
name?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
// Re‑mock constants to simulate missing ENCRYPTION_KEY
|
||||
vi.doMock("@formbricks/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: undefined,
|
||||
WEBAPP_URL: "https://test.formbricks.com",
|
||||
}));
|
||||
// Re‑import 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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!** 🚀
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
|
||||
<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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
43
docs/xm-and-surveys/enterprise-features/saml-sso.mdx
Normal file
43
docs/xm-and-surveys/enterprise-features/saml-sso.mdx
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -4,5 +4,4 @@ enum EventType: String, Codable {
|
||||
case onDisplayCreated = "onDisplayCreated"
|
||||
case onResponseCreated = "onResponseCreated"
|
||||
case onOpenExternalURL = "onOpenExternalURL"
|
||||
case onSurveyLibraryLoadError = "onSurveyLibraryLoadError"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`])
|
||||
})
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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'}'",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user