mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 19:39:01 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e3f0c53a4 |
@@ -1,9 +0,0 @@
|
|||||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
|
||||||
version = 1
|
|
||||||
name = "formbricks"
|
|
||||||
|
|
||||||
[setup]
|
|
||||||
script = '''
|
|
||||||
pnpm install
|
|
||||||
pnpm dev:setup
|
|
||||||
'''
|
|
||||||
+2
-40
@@ -94,12 +94,6 @@ EMAIL_VERIFICATION_DISABLED=1
|
|||||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||||
PASSWORD_RESET_DISABLED=1
|
PASSWORD_RESET_DISABLED=1
|
||||||
|
|
||||||
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
|
|
||||||
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
|
|
||||||
|
|
||||||
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
|
|
||||||
# DEBUG_SHOW_RESET_LINK=1
|
|
||||||
|
|
||||||
# Email login. Disable the ability for users to login with email.
|
# Email login. Disable the ability for users to login with email.
|
||||||
# EMAIL_AUTH_DISABLED=1
|
# EMAIL_AUTH_DISABLED=1
|
||||||
|
|
||||||
@@ -138,31 +132,6 @@ AZUREAD_CLIENT_ID=
|
|||||||
AZUREAD_CLIENT_SECRET=
|
AZUREAD_CLIENT_SECRET=
|
||||||
AZUREAD_TENANT_ID=
|
AZUREAD_TENANT_ID=
|
||||||
|
|
||||||
# Configure Formbricks AI at the instance level
|
|
||||||
# Set the provider used for AI features on this instance.
|
|
||||||
# Accepted values for AI_PROVIDER: aws, gcp, azure
|
|
||||||
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
|
|
||||||
# AI_PROVIDER=gcp
|
|
||||||
# AI_MODEL=gemini-2.5-flash
|
|
||||||
|
|
||||||
# Google Vertex AI credentials
|
|
||||||
# AI_GCP_PROJECT=
|
|
||||||
# AI_GCP_LOCATION=
|
|
||||||
# AI_GCP_CREDENTIALS_JSON=
|
|
||||||
# AI_GCP_APPLICATION_CREDENTIALS=
|
|
||||||
|
|
||||||
# Amazon Bedrock credentials
|
|
||||||
# AI_AWS_REGION=
|
|
||||||
# AI_AWS_ACCESS_KEY_ID=
|
|
||||||
# AI_AWS_SECRET_ACCESS_KEY=
|
|
||||||
# AI_AWS_SESSION_TOKEN=
|
|
||||||
|
|
||||||
# Azure AI / Microsoft Foundry credentials
|
|
||||||
# AI_AZURE_BASE_URL=
|
|
||||||
# AI_AZURE_RESOURCE_NAME=
|
|
||||||
# AI_AZURE_API_KEY=
|
|
||||||
# AI_AZURE_API_VERSION=v1
|
|
||||||
|
|
||||||
# OpenID Connect (OIDC) configuration
|
# OpenID Connect (OIDC) configuration
|
||||||
# OIDC_CLIENT_ID=
|
# OIDC_CLIENT_ID=
|
||||||
# OIDC_CLIENT_SECRET=
|
# OIDC_CLIENT_SECRET=
|
||||||
@@ -181,6 +150,7 @@ NOTION_OAUTH_CLIENT_ID=
|
|||||||
NOTION_OAUTH_CLIENT_SECRET=
|
NOTION_OAUTH_CLIENT_SECRET=
|
||||||
|
|
||||||
# Stripe Billing Variables
|
# Stripe Billing Variables
|
||||||
|
STRIPE_PRICING_TABLE_ID=
|
||||||
STRIPE_PUBLISHABLE_KEY=
|
STRIPE_PUBLISHABLE_KEY=
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
@@ -216,14 +186,6 @@ ENTERPRISE_LICENSE_KEY=
|
|||||||
# Ignore Rate Limiting across the Formbricks app
|
# Ignore Rate Limiting across the Formbricks app
|
||||||
# RATE_LIMITING_DISABLED=1
|
# RATE_LIMITING_DISABLED=1
|
||||||
|
|
||||||
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
|
|
||||||
# TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
|
|
||||||
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
|
|
||||||
# that need to send webhooks to internal services.
|
|
||||||
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
|
|
||||||
|
|
||||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||||
@@ -270,4 +232,4 @@ REDIS_URL=redis://localhost:6379
|
|||||||
|
|
||||||
|
|
||||||
# Lingo.dev API key for translation generation
|
# Lingo.dev API key for translation generation
|
||||||
LINGO_API_KEY=your_api_key_here
|
LINGODOTDEV_API_KEY=your_api_key_here
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ yarn-error.log*
|
|||||||
.direnv
|
.direnv
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
**/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
|||||||
+1
-13
@@ -1,13 +1 @@
|
|||||||
#!/usr/bin/env sh
|
pnpm lint-staged
|
||||||
|
|
||||||
if command -v pnpm >/dev/null 2>&1; then
|
|
||||||
pnpm lint-staged
|
|
||||||
elif command -v npm >/dev/null 2>&1; then
|
|
||||||
npm exec --yes pnpm@10.32.1 lint-staged
|
|
||||||
elif command -v corepack >/dev/null 2>&1; then
|
|
||||||
corepack pnpm lint-staged
|
|
||||||
else
|
|
||||||
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
|
|
||||||
echo "Install Node.js tooling or update your PATH, then retry the commit."
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
@@ -52,14 +52,6 @@ We are using SonarQube to identify code smells and security hotspots.
|
|||||||
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
||||||
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
||||||
|
|
||||||
## Date and Time Rendering
|
|
||||||
|
|
||||||
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
|
|
||||||
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
|
|
||||||
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
|
|
||||||
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
|
|
||||||
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
|
|
||||||
|
|
||||||
## Database & Prisma Performance
|
## Database & Prisma Performance
|
||||||
|
|
||||||
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||||
|
|||||||
@@ -127,10 +127,34 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
|
|||||||
|
|
||||||
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
||||||
|
|
||||||
|
If you opt for self-hosting Formbricks, here are a few options to consider:
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
|
|
||||||
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
||||||
|
|
||||||
|
#### Community-managed One Click Hosting
|
||||||
|
|
||||||
|
##### Railway
|
||||||
|
|
||||||
|
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
|
||||||
|
|
||||||
|
[](https://railway.app/new/template/PPDzCd)
|
||||||
|
|
||||||
|
##### RepoCloud
|
||||||
|
|
||||||
|
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
|
||||||
|
|
||||||
|
[](https://repocloud.io/details/?app_id=254)
|
||||||
|
|
||||||
|
##### Zeabur
|
||||||
|
|
||||||
|
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
|
||||||
|
|
||||||
|
[](https://zeabur.com/templates/G4TUJL)
|
||||||
|
|
||||||
|
<a id="development"></a>
|
||||||
|
|
||||||
## 👨💻 Development
|
## 👨💻 Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -223,4 +247,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
|
|||||||
|
|
||||||
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
||||||
|
|
||||||
<a id="readme-de"></a>
|
<p align="right"><a href="#top">🔼 Back to top</a></p>
|
||||||
|
|||||||
@@ -12,18 +12,18 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^5.0.1",
|
"@chromatic-com/storybook": "^5.0.1",
|
||||||
"@storybook/addon-a11y": "10.2.17",
|
"@storybook/addon-a11y": "10.2.15",
|
||||||
"@storybook/addon-links": "10.2.17",
|
"@storybook/addon-links": "10.2.15",
|
||||||
"@storybook/addon-onboarding": "10.2.17",
|
"@storybook/addon-onboarding": "10.2.15",
|
||||||
"@storybook/react-vite": "10.2.17",
|
"@storybook/react-vite": "10.2.15",
|
||||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||||
"@tailwindcss/vite": "4.2.1",
|
"@tailwindcss/vite": "4.2.1",
|
||||||
"@typescript-eslint/parser": "8.57.0",
|
"@typescript-eslint/parser": "8.56.1",
|
||||||
"@vitejs/plugin-react": "5.1.4",
|
"@vitejs/plugin-react": "5.1.4",
|
||||||
"eslint-plugin-react-refresh": "0.4.26",
|
"eslint-plugin-react-refresh": "0.4.26",
|
||||||
"eslint-plugin-storybook": "10.2.17",
|
"eslint-plugin-storybook": "10.2.14",
|
||||||
"storybook": "10.2.17",
|
"storybook": "10.2.15",
|
||||||
"vite": "7.3.1",
|
"vite": "7.3.1",
|
||||||
"@storybook/addon-docs": "10.2.17"
|
"@storybook/addon-docs": "10.2.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-6
@@ -122,11 +122,8 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
|
|||||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||||
|
|
||||||
# Runtime migrations import uuid v7 from the database package, so copy the
|
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
|
||||||
# database package's resolved install instead of the repo-root hoisted version.
|
RUN chmod -R 755 ./node_modules/uuid
|
||||||
COPY --from=installer /app/packages/database/node_modules/uuid ./node_modules/uuid
|
|
||||||
RUN chmod -R 755 ./node_modules/uuid \
|
|
||||||
&& node --input-type=module -e "import('uuid').then((module) => { if (typeof module.v7 !== 'function') throw new Error('uuid v7 missing in runtime image'); })"
|
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
||||||
RUN chmod -R 755 ./node_modules/@noble/hashes
|
RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||||
@@ -169,4 +166,4 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
|
|||||||
VOLUME /home/nextjs/apps/web/uploads/
|
VOLUME /home/nextjs/apps/web/uploads/
|
||||||
VOLUME /home/nextjs/apps/web/saml-connection
|
VOLUME /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
CMD ["/home/nextjs/start.sh"]
|
CMD ["/home/nextjs/start.sh"]
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
@@ -21,12 +20,12 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
|
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
throw new Error(t("common.environment_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = project.config.channel || null;
|
const channel = project.config.channel || null;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
||||||
@@ -24,22 +23,22 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.session_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
throw new Error(t("common.environment_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, organizationId);
|
const projects = await getUserProjects(session.user.id, organizationId);
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ export const getTeamsByOrganizationId = reactCache(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return teams.map((team: TOrganizationTeam) => ({
|
const projectTeams = teams.map((team) => ({
|
||||||
id: team.id,
|
id: team.id,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return projectTeams;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
throw new DatabaseError(error.message);
|
throw new DatabaseError(error.message);
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
|||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
|
|
||||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||||
const { isMember, isBilling } = getAccessFlags(membership?.role);
|
const { isMember } = getAccessFlags(membership?.role);
|
||||||
const isMembershipPending = membership?.role === undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-row">
|
<div className="flex min-h-full min-w-full flex-row">
|
||||||
@@ -46,8 +45,6 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
|||||||
isOwnerOrManager={false}
|
isOwnerOrManager={false}
|
||||||
isAccessControlAllowed={false}
|
isAccessControlAllowed={false}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
isBilling={isBilling}
|
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
environments={[]}
|
environments={[]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { AuthorizationError } from "@formbricks/types/errors";
|
||||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
@@ -25,7 +25,7 @@ const ProjectOnboardingLayout = async (props: {
|
|||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
||||||
@@ -36,7 +36,7 @@ const ProjectOnboardingLayout = async (props: {
|
|||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
const organization = await getOrganization(params.organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
@@ -29,7 +28,7 @@ const OnboardingLayout = async (props: {
|
|||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
const organization = await getOrganization(params.organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
|
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
|
||||||
|
|||||||
-14
@@ -1,12 +1,8 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
|
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
|
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
|
||||||
|
|
||||||
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
|
||||||
|
|
||||||
interface PlanPageProps {
|
interface PlanPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -26,16 +22,6 @@ const Page = async (props: PlanPageProps) => {
|
|||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users with an existing paid/trial subscription should not be shown the trial page.
|
|
||||||
// Redirect them directly to the next onboarding step.
|
|
||||||
const billing = await getOrganizationBillingWithReadThroughSync(params.organizationId);
|
|
||||||
const currentPlan = billing?.stripe?.plan;
|
|
||||||
const hasExistingSubscription = currentPlan !== undefined && PAID_PLANS.has(currentPlan);
|
|
||||||
|
|
||||||
if (hasExistingSubscription) {
|
|
||||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <SelectPlanOnboarding organizationId={params.organizationId} />;
|
return <SelectPlanOnboarding organizationId={params.organizationId} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||||
@@ -46,7 +45,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||||
|
|
||||||
if (!organizationTeams) {
|
if (!organizationTeams) {
|
||||||
throw new ResourceNotFoundError(t("common.team"), null);
|
throw new Error(t("common.organization_teams_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
|
|
||||||
@@ -18,13 +17,13 @@ const SurveyEditorEnvironmentLayout = async (props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
|
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
throw new Error(t("common.environment_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,11 +2,7 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import {
|
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||||
AuthorizationError,
|
|
||||||
OperationNotAllowedError,
|
|
||||||
ResourceNotFoundError,
|
|
||||||
} from "@formbricks/types/errors";
|
|
||||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
@@ -50,7 +46,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
|
|||||||
const organization = await getOrganization(organizationId);
|
const organization = await getOrganization(organizationId);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new ResourceNotFoundError("Organization", organizationId);
|
throw new Error("Organization not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
|
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
@@ -43,7 +42,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
|
|
||||||
// Validate that project permission exists for members
|
// Validate that project permission exists for members
|
||||||
if (isMember && !projectPermission) {
|
if (isMember && !projectPermission) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error(t("common.workspace_permission_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,10 +74,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
isDevelopment={IS_DEVELOPMENT}
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membership.role}
|
membershipRole={membership.role}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
|
||||||
isLicenseActive={active}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
/>
|
/>
|
||||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||||
<TopControlBar
|
<TopControlBar
|
||||||
|
|||||||
@@ -2,59 +2,41 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowUpRightIcon,
|
ArrowUpRightIcon,
|
||||||
Building2Icon,
|
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Cog,
|
Cog,
|
||||||
FoldersIcon,
|
|
||||||
Loader2,
|
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
PlusIcon,
|
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
SettingsIcon,
|
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
|
WorkflowIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import {
|
|
||||||
getOrganizationsForSwitcherAction,
|
|
||||||
getProjectsForSwitcherAction,
|
|
||||||
} from "@/app/(app)/environments/[environmentId]/actions";
|
|
||||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
|
||||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
|
||||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
|
||||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
|
||||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
|
||||||
import packageJson from "../../../../../package.json";
|
import packageJson from "../../../../../package.json";
|
||||||
|
|
||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
@@ -66,31 +48,8 @@ interface NavigationProps {
|
|||||||
isDevelopment: boolean;
|
isDevelopment: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
isMultiOrgEnabled: boolean;
|
|
||||||
organizationProjectsLimit: number;
|
|
||||||
isLicenseActive: boolean;
|
|
||||||
isAccessControlAllowed: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
|
||||||
if (pathname.includes("/settings/")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
|
||||||
return pattern.test(pathname);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
|
||||||
const accountSettingsPattern = /\/settings\/(profile|account|notifications|security|appearance)(?:\/|$)/;
|
|
||||||
if (accountSettingsPattern.test(pathname)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
|
|
||||||
return pattern.test(pathname);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
environment,
|
environment,
|
||||||
organization,
|
organization,
|
||||||
@@ -100,10 +59,6 @@ export const MainNavigation = ({
|
|||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
isMultiOrgEnabled,
|
|
||||||
organizationProjectsLimit,
|
|
||||||
isLicenseActive,
|
|
||||||
isAccessControlAllowed,
|
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -113,12 +68,7 @@ export const MainNavigation = ({
|
|||||||
const [latestVersion, setLatestVersion] = useState("");
|
const [latestVersion, setLatestVersion] = useState("");
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||||
const { isManager, isOwner, isBilling, isMember } = getAccessFlags(membershipRole);
|
|
||||||
const isMembershipPending = membershipRole === undefined;
|
|
||||||
const disabledNavigationMessage = isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action");
|
|
||||||
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
@@ -155,7 +105,6 @@ export const MainNavigation = ({
|
|||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
isActive: pathname?.includes("/surveys"),
|
isActive: pathname?.includes("/surveys"),
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
disabled: isMembershipPending || isBilling,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: `/environments/${environment.id}/contacts`,
|
href: `/environments/${environment.id}/contacts`,
|
||||||
@@ -165,17 +114,22 @@ export const MainNavigation = ({
|
|||||||
pathname?.includes("/contacts") ||
|
pathname?.includes("/contacts") ||
|
||||||
pathname?.includes("/segments") ||
|
pathname?.includes("/segments") ||
|
||||||
pathname?.includes("/attributes"),
|
pathname?.includes("/attributes"),
|
||||||
disabled: isMembershipPending || isBilling,
|
},
|
||||||
|
{
|
||||||
|
name: t("common.workflows"),
|
||||||
|
href: `/environments/${environment.id}/workflows`,
|
||||||
|
icon: WorkflowIcon,
|
||||||
|
isActive: pathname?.includes("/workflows"),
|
||||||
|
isHidden: !isFormbricksCloud,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("common.configuration"),
|
name: t("common.configuration"),
|
||||||
href: `/environments/${environment.id}/workspace/general`,
|
href: `/environments/${environment.id}/workspace/general`,
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
isActive: pathname?.includes("/workspace"),
|
isActive: pathname?.includes("/workspace"),
|
||||||
disabled: isMembershipPending || isBilling,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, environment.id, pathname, isMembershipPending, isBilling]
|
[t, environment.id, pathname, isFormbricksCloud]
|
||||||
);
|
);
|
||||||
|
|
||||||
const dropdownNavigation = [
|
const dropdownNavigation = [
|
||||||
@@ -198,183 +152,6 @@ export const MainNavigation = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
|
|
||||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
|
||||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
|
||||||
const [hasInitializedProjects, setHasInitializedProjects] = useState(false);
|
|
||||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
|
||||||
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
|
|
||||||
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
|
|
||||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
|
||||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
|
||||||
const [openProjectLimitModal, setOpenProjectLimitModal] = useState(false);
|
|
||||||
|
|
||||||
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
|
|
||||||
<div className="px-2 py-4">
|
|
||||||
<p className="mb-2 text-sm text-red-600">{error}</p>
|
|
||||||
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
|
|
||||||
{retryLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectSettings = [
|
|
||||||
{
|
|
||||||
id: "general",
|
|
||||||
label: t("common.general"),
|
|
||||||
href: `/environments/${environment.id}/workspace/general`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "look",
|
|
||||||
label: t("common.look_and_feel"),
|
|
||||||
href: `/environments/${environment.id}/workspace/look`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "app-connection",
|
|
||||||
label: t("common.website_and_app_connection"),
|
|
||||||
href: `/environments/${environment.id}/workspace/app-connection`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "integrations",
|
|
||||||
label: t("common.integrations"),
|
|
||||||
href: `/environments/${environment.id}/workspace/integrations`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "teams",
|
|
||||||
label: t("common.team_access"),
|
|
||||||
href: `/environments/${environment.id}/workspace/teams`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "languages",
|
|
||||||
label: t("common.survey_languages"),
|
|
||||||
href: `/environments/${environment.id}/workspace/languages`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tags",
|
|
||||||
label: t("common.tags"),
|
|
||||||
href: `/environments/${environment.id}/workspace/tags`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const organizationSettings = [
|
|
||||||
{
|
|
||||||
id: "general",
|
|
||||||
label: t("common.general"),
|
|
||||||
href: `/environments/${environment.id}/settings/general`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "teams",
|
|
||||||
label: t("common.members_and_teams"),
|
|
||||||
href: `/environments/${environment.id}/settings/teams`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "api-keys",
|
|
||||||
label: t("common.api_keys"),
|
|
||||||
href: `/environments/${environment.id}/settings/api-keys`,
|
|
||||||
hidden: !isOwnerOrManager,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "domain",
|
|
||||||
label: t("common.domain"),
|
|
||||||
href: `/environments/${environment.id}/settings/domain`,
|
|
||||||
hidden: isFormbricksCloud,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "billing",
|
|
||||||
label: t("common.billing"),
|
|
||||||
href: `/environments/${environment.id}/settings/billing`,
|
|
||||||
hidden: !isFormbricksCloud,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "enterprise",
|
|
||||||
label: t("common.enterprise_license"),
|
|
||||||
href: `/environments/${environment.id}/settings/enterprise`,
|
|
||||||
hidden: isFormbricksCloud || isMember,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const loadProjects = useCallback(async () => {
|
|
||||||
setIsLoadingProjects(true);
|
|
||||||
setWorkspaceLoadError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await getProjectsForSwitcherAction({ organizationId: organization.id });
|
|
||||||
if (result?.data) {
|
|
||||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
setProjects(sorted);
|
|
||||||
} else {
|
|
||||||
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const formattedError =
|
|
||||||
typeof error === "object" && error !== null
|
|
||||||
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
|
|
||||||
: "";
|
|
||||||
setWorkspaceLoadError(
|
|
||||||
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_workspaces"))
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingProjects(false);
|
|
||||||
setHasInitializedProjects(true);
|
|
||||||
}
|
|
||||||
}, [organization.id, t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isWorkspaceDropdownOpen || projects.length > 0 || isLoadingProjects || workspaceLoadError) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadProjects();
|
|
||||||
}, [isWorkspaceDropdownOpen, projects.length, isLoadingProjects, workspaceLoadError, loadProjects]);
|
|
||||||
|
|
||||||
const loadOrganizations = useCallback(async () => {
|
|
||||||
setIsLoadingOrganizations(true);
|
|
||||||
setOrganizationLoadError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
|
|
||||||
if (result?.data) {
|
|
||||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
setOrganizations(sorted);
|
|
||||||
} else {
|
|
||||||
setOrganizationLoadError(
|
|
||||||
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const formattedError =
|
|
||||||
typeof error === "object" && error !== null
|
|
||||||
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
|
|
||||||
: "";
|
|
||||||
setOrganizationLoadError(
|
|
||||||
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_organizations"))
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingOrganizations(false);
|
|
||||||
}
|
|
||||||
}, [organization.id, t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isOrganizationDropdownOpen ||
|
|
||||||
organizations.length > 0 ||
|
|
||||||
isLoadingOrganizations ||
|
|
||||||
organizationLoadError
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadOrganizations();
|
|
||||||
}, [
|
|
||||||
isOrganizationDropdownOpen,
|
|
||||||
organizations.length,
|
|
||||||
isLoadingOrganizations,
|
|
||||||
organizationLoadError,
|
|
||||||
loadOrganizations,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadReleases() {
|
async function loadReleases() {
|
||||||
const res = await getLatestStableFbReleaseAction();
|
const res = await getLatestStableFbReleaseAction();
|
||||||
@@ -390,93 +167,7 @@ export const MainNavigation = ({
|
|||||||
if (isOwnerOrManager) loadReleases();
|
if (isOwnerOrManager) loadReleases();
|
||||||
}, [isOwnerOrManager]);
|
}, [isOwnerOrManager]);
|
||||||
|
|
||||||
const trialDaysRemaining = useMemo(() => {
|
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||||
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
|
|
||||||
const trialEnd = organization.billing.stripe.trialEnd;
|
|
||||||
if (!trialEnd) return null;
|
|
||||||
const ts = new Date(trialEnd).getTime();
|
|
||||||
if (!Number.isFinite(ts)) return null;
|
|
||||||
const msPerDay = 86_400_000;
|
|
||||||
return Math.ceil((ts - Date.now()) / msPerDay);
|
|
||||||
}, [
|
|
||||||
isFormbricksCloud,
|
|
||||||
organization.billing?.stripe?.subscriptionStatus,
|
|
||||||
organization.billing?.stripe?.trialEnd,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const mainNavigationLink = isBilling
|
|
||||||
? getBillingFallbackPath(environment.id, isFormbricksCloud)
|
|
||||||
: `/environments/${environment.id}/surveys/`;
|
|
||||||
|
|
||||||
const handleProjectChange = (projectId: string) => {
|
|
||||||
if (projectId === project.id) return;
|
|
||||||
startTransition(() => {
|
|
||||||
router.push(`/workspaces/${projectId}/`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOrganizationChange = (organizationId: string) => {
|
|
||||||
if (organizationId === organization.id) return;
|
|
||||||
startTransition(() => {
|
|
||||||
router.push(`/organizations/${organizationId}/`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingNavigation = (href: string) => {
|
|
||||||
startTransition(() => {
|
|
||||||
router.push(href);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProjectCreate = () => {
|
|
||||||
if (!hasInitializedProjects || isLoadingProjects) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projects.length >= organizationProjectsLimit) {
|
|
||||||
setOpenProjectLimitModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpenCreateProjectModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const projectLimitModalButtons = (): [ModalButton, ModalButton] => {
|
|
||||||
if (isFormbricksCloud) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: t("environments.settings.billing.upgrade"),
|
|
||||||
href: `/environments/${environment.id}/settings/billing`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.cancel"),
|
|
||||||
onClick: () => setOpenProjectLimitModal(false),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: t("environments.settings.billing.upgrade"),
|
|
||||||
href: isLicenseActive
|
|
||||||
? `/environments/${environment.id}/settings/enterprise`
|
|
||||||
: "https://formbricks.com/upgrade-self-hosted-license",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.cancel"),
|
|
||||||
onClick: () => setOpenProjectLimitModal(false),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const switcherTriggerClasses = cn(
|
|
||||||
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset",
|
|
||||||
isCollapsed ? "flex items-center justify-center" : ""
|
|
||||||
);
|
|
||||||
|
|
||||||
const switcherIconClasses =
|
|
||||||
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
|
|
||||||
const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -516,24 +207,24 @@ export const MainNavigation = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Nav Switch */}
|
{/* Main Nav Switch */}
|
||||||
<ul>
|
{!isBilling && (
|
||||||
{mainNavigation.map(
|
<ul>
|
||||||
(item) =>
|
{mainNavigation.map(
|
||||||
!item.isHidden && (
|
(item) =>
|
||||||
<NavigationLink
|
!item.isHidden && (
|
||||||
key={item.name}
|
<NavigationLink
|
||||||
href={item.href}
|
key={item.name}
|
||||||
isActive={item.isActive}
|
href={item.href}
|
||||||
isCollapsed={isCollapsed}
|
isActive={item.isActive}
|
||||||
isTextVisible={isTextVisible}
|
isCollapsed={isCollapsed}
|
||||||
disabled={item.disabled}
|
isTextVisible={isTextVisible}
|
||||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
linkText={item.name}>
|
||||||
linkText={item.name}>
|
<item.icon strokeWidth={1.5} />
|
||||||
<item.icon strokeWidth={1.5} />
|
</NavigationLink>
|
||||||
</NavigationLink>
|
)
|
||||||
)
|
)}
|
||||||
)}
|
</ul>
|
||||||
</ul>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -550,217 +241,38 @@ export const MainNavigation = ({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Trial Days Remaining */}
|
{/* User Switch */}
|
||||||
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
|
<div className="flex items-center">
|
||||||
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
|
|
||||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
|
||||||
<DropdownMenuTrigger asChild id="workspaceDropdownTrigger" className={switcherTriggerClasses}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
|
|
||||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
|
||||||
<span className={switcherIconClasses}>
|
|
||||||
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</span>
|
|
||||||
{!isCollapsed && !isTextVisible && (
|
|
||||||
<>
|
|
||||||
<div className="grow overflow-hidden">
|
|
||||||
<p className="truncate text-sm font-bold text-slate-700">{project.name}</p>
|
|
||||||
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
|
|
||||||
</div>
|
|
||||||
{isPending && (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
|
|
||||||
)}
|
|
||||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{t("common.change_workspace")}
|
|
||||||
</div>
|
|
||||||
{(isLoadingProjects || isInitialProjectsLoading) && (
|
|
||||||
<div className="flex items-center justify-center py-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoadingProjects &&
|
|
||||||
!isInitialProjectsLoading &&
|
|
||||||
workspaceLoadError &&
|
|
||||||
renderSwitcherError(
|
|
||||||
workspaceLoadError,
|
|
||||||
() => {
|
|
||||||
setWorkspaceLoadError(null);
|
|
||||||
setProjects([]);
|
|
||||||
},
|
|
||||||
t("common.try_again")
|
|
||||||
)}
|
|
||||||
{!isLoadingProjects && !isInitialProjectsLoading && !workspaceLoadError && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
|
||||||
{projects.map((proj) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={proj.id}
|
|
||||||
checked={proj.id === project.id}
|
|
||||||
onClick={() => handleProjectChange(proj.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{proj.name}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
{isOwnerOrManager && (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
onClick={handleProjectCreate}
|
|
||||||
className="w-full cursor-pointer justify-between">
|
|
||||||
<span>{t("common.add_new_workspace")}</span>
|
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<Cog className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{t("common.workspace_configuration")}
|
|
||||||
</div>
|
|
||||||
{projectSettings.map((setting) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={setting.id}
|
|
||||||
checked={isActiveProjectSetting(pathname, setting.id)}
|
|
||||||
onClick={() => handleSettingNavigation(setting.href)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{setting.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
asChild
|
|
||||||
id="organizationDropdownTriggerSidebar"
|
|
||||||
className={switcherTriggerClasses}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={isCollapsed ? t("common.change_organization") : undefined}
|
|
||||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
|
||||||
<span className={switcherIconClasses}>
|
|
||||||
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</span>
|
|
||||||
{!isCollapsed && !isTextVisible && (
|
|
||||||
<>
|
|
||||||
<div className="grow overflow-hidden">
|
|
||||||
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
|
|
||||||
<p className="text-sm text-slate-500">{t("common.organization")}</p>
|
|
||||||
</div>
|
|
||||||
{isPending && (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
|
|
||||||
)}
|
|
||||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{t("common.change_organization")}
|
|
||||||
</div>
|
|
||||||
{isLoadingOrganizations && (
|
|
||||||
<div className="flex items-center justify-center py-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoadingOrganizations &&
|
|
||||||
organizationLoadError &&
|
|
||||||
renderSwitcherError(
|
|
||||||
organizationLoadError,
|
|
||||||
() => {
|
|
||||||
setOrganizationLoadError(null);
|
|
||||||
setOrganizations([]);
|
|
||||||
},
|
|
||||||
t("common.try_again")
|
|
||||||
)}
|
|
||||||
{!isLoadingOrganizations && !organizationLoadError && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
|
||||||
{organizations.map((org) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={org.id}
|
|
||||||
checked={org.id === organization.id}
|
|
||||||
onClick={() => handleOrganizationChange(org.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{org.name}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
{isMultiOrgEnabled && (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
|
||||||
className="w-full cursor-pointer justify-between">
|
|
||||||
<span>{t("common.create_new_organization")}</span>
|
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<SettingsIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{t("common.organization_settings")}
|
|
||||||
</div>
|
|
||||||
{organizationSettings.map((setting) => {
|
|
||||||
if (setting.hidden) return null;
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={setting.id}
|
|
||||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
|
||||||
onClick={() => handleSettingNavigation(setting.href)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{setting.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
asChild
|
asChild
|
||||||
id="userDropdownTrigger"
|
id="userDropdownTrigger"
|
||||||
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||||
<button
|
<div
|
||||||
type="button"
|
className={cn(
|
||||||
aria-label={isCollapsed ? t("common.account_settings") : undefined}
|
"flex cursor-pointer flex-row items-center gap-3",
|
||||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
isCollapsed ? "justify-center px-2" : "px-4"
|
||||||
<span className={switcherIconClasses}>
|
)}>
|
||||||
<ProfileAvatar userId={user.id} />
|
<ProfileAvatar userId={user.id} />
|
||||||
</span>
|
|
||||||
{!isCollapsed && !isTextVisible && (
|
{!isCollapsed && !isTextVisible && (
|
||||||
<>
|
<>
|
||||||
<div className="grow overflow-hidden">
|
<div
|
||||||
|
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
||||||
<p
|
<p
|
||||||
title={user?.email}
|
title={user?.email}
|
||||||
className="ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
|
className={cn(
|
||||||
|
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
||||||
|
)}>
|
||||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-500">{t("common.account")}</p>
|
<p className="text-sm text-slate-700">{t("common.account")}</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
<ChevronRightIcon
|
||||||
|
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
@@ -769,6 +281,8 @@ export const MainNavigation = ({
|
|||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
alignOffset={5}
|
alignOffset={5}
|
||||||
align="end">
|
align="end">
|
||||||
|
{/* Dropdown Items */}
|
||||||
|
|
||||||
{dropdownNavigation.map((link) => (
|
{dropdownNavigation.map((link) => (
|
||||||
<Link
|
<Link
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@@ -782,6 +296,7 @@ export const MainNavigation = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{/* Logout */}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const loginUrl = `${publicDomain}/auth/login`;
|
const loginUrl = `${publicDomain}/auth/login`;
|
||||||
@@ -804,28 +319,6 @@ export const MainNavigation = ({
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
{openProjectLimitModal && (
|
|
||||||
<ProjectLimitModal
|
|
||||||
open={openProjectLimitModal}
|
|
||||||
setOpen={setOpenProjectLimitModal}
|
|
||||||
buttons={projectLimitModalButtons()}
|
|
||||||
projectLimit={organizationProjectsLimit}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{openCreateProjectModal && (
|
|
||||||
<CreateProjectModal
|
|
||||||
open={openCreateProjectModal}
|
|
||||||
setOpen={setOpenCreateProjectModal}
|
|
||||||
organizationId={organization.id}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{openCreateOrganizationModal && (
|
|
||||||
<CreateOrganizationModal
|
|
||||||
open={openCreateOrganizationModal}
|
|
||||||
setOpen={setOpenCreateOrganizationModal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
interface NavigationLinkProps {
|
interface NavigationLinkProps {
|
||||||
@@ -11,8 +10,6 @@ interface NavigationLinkProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
linkText: string;
|
linkText: string;
|
||||||
isTextVisible: boolean;
|
isTextVisible: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
disabledMessage?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NavigationLink = ({
|
export const NavigationLink = ({
|
||||||
@@ -22,34 +19,10 @@ export const NavigationLink = ({
|
|||||||
children,
|
children,
|
||||||
linkText,
|
linkText,
|
||||||
isTextVisible = true,
|
isTextVisible = true,
|
||||||
disabled = false,
|
|
||||||
disabledMessage,
|
|
||||||
}: NavigationLinkProps) => {
|
}: NavigationLinkProps) => {
|
||||||
const tooltipText = disabled ? disabledMessage || linkText : linkText;
|
|
||||||
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
||||||
const inactiveClass =
|
const inactiveClass =
|
||||||
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
|
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
|
||||||
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
|
|
||||||
const getColorClass = (baseClass: string) => {
|
|
||||||
if (disabled) {
|
|
||||||
return disabledClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cn(baseClass, isActive ? activeClass : inactiveClass);
|
|
||||||
};
|
|
||||||
|
|
||||||
const collapsedColorClass = getColorClass("text-slate-700 hover:text-slate-900");
|
|
||||||
const expandedColorClass = getColorClass("text-slate-600 hover:text-slate-900");
|
|
||||||
|
|
||||||
const label = (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-2 flex transition-opacity duration-100",
|
|
||||||
isTextVisible ? "opacity-0" : "opacity-100"
|
|
||||||
)}>
|
|
||||||
{linkText}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -57,37 +30,35 @@ export const NavigationLink = ({
|
|||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<li className={cn("mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm", collapsedColorClass)}>
|
<li
|
||||||
{disabled ? (
|
className={cn(
|
||||||
<div className="flex items-center">{children}</div>
|
"mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm text-slate-700 hover:text-slate-900",
|
||||||
) : (
|
isActive ? activeClass : inactiveClass
|
||||||
<Link href={href}>{children}</Link>
|
)}>
|
||||||
)}
|
<Link href={href} className="flex items-center">
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{tooltipText}</TooltipContent>
|
<TooltipContent side="right">{linkText}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : (
|
) : (
|
||||||
<li className={cn("mb-1 rounded-l-md py-2 pl-5 text-sm", expandedColorClass)}>
|
<li
|
||||||
{disabled ? (
|
className={cn(
|
||||||
<Popover>
|
"mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900",
|
||||||
<PopoverTrigger asChild>
|
isActive ? activeClass : inactiveClass
|
||||||
<div className="flex items-center">
|
)}>
|
||||||
{children}
|
<Link href={href} className="flex items-center">
|
||||||
{label}
|
{children}
|
||||||
</div>
|
<span
|
||||||
</PopoverTrigger>
|
className={cn(
|
||||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
"ml-2 flex transition-opacity duration-100",
|
||||||
{disabledMessage || linkText}
|
isTextVisible ? "opacity-0" : "opacity-100"
|
||||||
</PopoverContent>
|
)}>
|
||||||
</Popover>
|
{linkText}
|
||||||
) : (
|
</span>
|
||||||
<Link href={href} className="flex items-center">
|
</Link>
|
||||||
{children}
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ export const TopControlBar = ({
|
|||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
membershipRole,
|
membershipRole,
|
||||||
}: TopControlBarProps) => {
|
}: TopControlBarProps) => {
|
||||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
const { isMember } = getAccessFlags(membershipRole);
|
||||||
const isMembershipPending = membershipRole === undefined;
|
|
||||||
const { environment } = useEnvironment();
|
const { environment } = useEnvironment();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,8 +49,6 @@ export const TopControlBar = ({
|
|||||||
isLicenseActive={isLicenseActive}
|
isLicenseActive={isLicenseActive}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
isBilling={isBilling}
|
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
-36
@@ -25,7 +25,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
|
||||||
import { useOrganization } from "../context/environment-context";
|
import { useOrganization } from "../context/environment-context";
|
||||||
|
|
||||||
interface OrganizationBreadcrumbProps {
|
interface OrganizationBreadcrumbProps {
|
||||||
@@ -36,7 +35,6 @@ interface OrganizationBreadcrumbProps {
|
|||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
isMembershipPending: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
||||||
@@ -58,7 +56,6 @@ export const OrganizationBreadcrumb = ({
|
|||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isMember,
|
isMember,
|
||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
isMembershipPending,
|
|
||||||
}: OrganizationBreadcrumbProps) => {
|
}: OrganizationBreadcrumbProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||||
@@ -145,10 +142,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
id: "api-keys",
|
id: "api-keys",
|
||||||
label: t("common.api_keys"),
|
label: t("common.api_keys"),
|
||||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||||
disabled: isMembershipPending || !isOwnerOrManager,
|
hidden: !isOwnerOrManager,
|
||||||
disabledMessage: isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "domain",
|
id: "domain",
|
||||||
@@ -166,11 +160,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
id: "enterprise",
|
id: "enterprise",
|
||||||
label: t("common.enterprise_license"),
|
label: t("common.enterprise_license"),
|
||||||
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
||||||
hidden: isFormbricksCloud,
|
hidden: isFormbricksCloud || isMember,
|
||||||
disabled: isMembershipPending || isMember,
|
|
||||||
disabledMessage: isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -252,30 +242,14 @@ export const OrganizationBreadcrumb = ({
|
|||||||
|
|
||||||
{organizationSettings.map((setting) => {
|
{organizationSettings.map((setting) => {
|
||||||
return setting.hidden ? null : (
|
return setting.hidden ? null : (
|
||||||
<div key={setting.id}>
|
<DropdownMenuCheckboxItem
|
||||||
{setting.disabled ? (
|
key={setting.id}
|
||||||
<Popover>
|
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||||
<PopoverTrigger asChild>
|
hidden={setting.hidden}
|
||||||
<button
|
onClick={() => handleSettingChange(setting.href)}
|
||||||
type="button"
|
className="cursor-pointer">
|
||||||
aria-disabled="true"
|
{setting.label}
|
||||||
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
</DropdownMenuCheckboxItem>
|
||||||
{setting.label}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
|
||||||
{setting.disabledMessage}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
|
||||||
onClick={() => handleSettingChange(setting.href)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{setting.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ interface ProjectAndOrgSwitchProps {
|
|||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
isBilling: boolean;
|
|
||||||
isMembershipPending: boolean;
|
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +35,6 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isMember,
|
isMember,
|
||||||
isBilling,
|
|
||||||
isMembershipPending,
|
|
||||||
}: ProjectAndOrgSwitchProps) => {
|
}: ProjectAndOrgSwitchProps) => {
|
||||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||||
@@ -54,7 +50,6 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
/>
|
/>
|
||||||
{currentProjectId && currentEnvironmentId && (
|
{currentProjectId && currentEnvironmentId && (
|
||||||
<ProjectBreadcrumb
|
<ProjectBreadcrumb
|
||||||
@@ -68,8 +63,6 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isLicenseActive={isLicenseActive}
|
isLicenseActive={isLicenseActive}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
||||||
isBilling={isBilling}
|
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showEnvironmentBreadcrumb && (
|
{showEnvironmentBreadcrumb && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
|
||||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||||
import { useProject } from "../context/environment-context";
|
import { useProject } from "../context/environment-context";
|
||||||
|
|
||||||
@@ -34,8 +33,6 @@ interface ProjectBreadcrumbProps {
|
|||||||
currentEnvironmentId: string;
|
currentEnvironmentId: string;
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
isEnvironmentBreadcrumbVisible: boolean;
|
isEnvironmentBreadcrumbVisible: boolean;
|
||||||
isBilling: boolean;
|
|
||||||
isMembershipPending: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||||
@@ -59,8 +56,6 @@ export const ProjectBreadcrumb = ({
|
|||||||
currentEnvironmentId,
|
currentEnvironmentId,
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isEnvironmentBreadcrumbVisible,
|
isEnvironmentBreadcrumbVisible,
|
||||||
isBilling,
|
|
||||||
isMembershipPending,
|
|
||||||
}: ProjectBreadcrumbProps) => {
|
}: ProjectBreadcrumbProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
||||||
@@ -139,10 +134,6 @@ export const ProjectBreadcrumb = ({
|
|||||||
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const areProjectSettingsDisabled = isMembershipPending || isBilling;
|
|
||||||
const projectSettingsDisabledMessage = isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action");
|
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||||
@@ -207,7 +198,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
id="projectDropdownTrigger"
|
id="projectDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
|
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{projectName}</span>
|
<span>{projectName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||||
@@ -220,7 +211,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
|
|
||||||
<DropdownMenuContent align="start" className="mt-2">
|
<DropdownMenuContent align="start" className="mt-2">
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.choose_workspace")}
|
{t("common.choose_workspace")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingProjects && (
|
{isLoadingProjects && (
|
||||||
@@ -256,24 +247,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
{isMembershipPending || !isOwnerOrManager ? (
|
{isOwnerOrManager && (
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-disabled="true"
|
|
||||||
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
|
||||||
<span>{t("common.add_new_workspace")}</span>
|
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
|
||||||
{isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action")}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
onClick={handleAddProject}
|
onClick={handleAddProject}
|
||||||
className="w-full cursor-pointer justify-between">
|
className="w-full cursor-pointer justify-between">
|
||||||
@@ -290,30 +264,13 @@ export const ProjectBreadcrumb = ({
|
|||||||
{t("common.workspace_configuration")}
|
{t("common.workspace_configuration")}
|
||||||
</div>
|
</div>
|
||||||
{projectSettings.map((setting) => (
|
{projectSettings.map((setting) => (
|
||||||
<div key={setting.id}>
|
<DropdownMenuCheckboxItem
|
||||||
{areProjectSettingsDisabled ? (
|
key={setting.id}
|
||||||
<Popover>
|
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||||
<PopoverTrigger asChild>
|
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
||||||
<button
|
className="cursor-pointer">
|
||||||
type="button"
|
{setting.label}
|
||||||
aria-disabled="true"
|
</DropdownMenuCheckboxItem>
|
||||||
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
|
||||||
{setting.label}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
|
||||||
{projectSettingsDisabledMessage}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
checked={isActiveProjectSetting(pathname, setting.id)}
|
|
||||||
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{setting.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
@@ -13,7 +12,11 @@ const EnvironmentPage = async (props: { params: Promise<{ environmentId: string
|
|||||||
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||||
|
|
||||||
if (isBilling) {
|
if (isBilling) {
|
||||||
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
|
if (IS_FORMBRICKS_CLOUD) {
|
||||||
|
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||||
|
} else {
|
||||||
|
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect(`/environments/${params.environmentId}/surveys`);
|
return redirect(`/environments/${params.environmentId}/surveys`);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
@@ -21,15 +20,15 @@ const AccountSettingsLayout = async (props: {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.session_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
+3
-4
@@ -1,6 +1,5 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||||
@@ -147,18 +146,18 @@ const Page = async (props: {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.session_not_found"));
|
||||||
}
|
}
|
||||||
const autoDisableNotificationType = searchParams["type"];
|
const autoDisableNotificationType = searchParams["type"];
|
||||||
const autoDisableNotificationElementId = searchParams["elementId"];
|
const autoDisableNotificationElementId = searchParams["elementId"];
|
||||||
|
|
||||||
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!memberships) {
|
if (!memberships) {
|
||||||
throw new ResourceNotFoundError(t("common.membership"), null);
|
throw new Error(t("common.membership_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user?.notificationSettings) {
|
if (user?.notificationSettings) {
|
||||||
|
|||||||
+3
-8
@@ -10,16 +10,15 @@ import {
|
|||||||
getIsEmailUnique,
|
getIsEmailUnique,
|
||||||
verifyUserPassword,
|
verifyUserPassword,
|
||||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||||
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||||
import { getUser, updateUser } from "@/lib/user/service";
|
import { getUser, updateUser } from "@/lib/user/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||||
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
|
|
||||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { sendVerificationNewEmail } from "@/modules/email";
|
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||||
|
|
||||||
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
|
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
|
||||||
return {
|
return {
|
||||||
@@ -86,15 +85,11 @@ export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPerso
|
|||||||
|
|
||||||
export const resetPasswordAction = authenticatedActionClient.action(
|
export const resetPasswordAction = authenticatedActionClient.action(
|
||||||
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
|
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
|
||||||
if (PASSWORD_RESET_DISABLED) {
|
|
||||||
throw new OperationNotAllowedError("Password reset is disabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.user.identityProvider !== "email") {
|
if (ctx.user.identityProvider !== "email") {
|
||||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await requestPasswordReset(ctx.user, "profile");
|
await sendForgotPasswordEmail(ctx.user);
|
||||||
|
|
||||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -116,14 +116,10 @@ export const EditProfileDetailsForm = ({
|
|||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const result = await updateUserAction({
|
await updateUserAction({
|
||||||
...data,
|
...data,
|
||||||
name: data.name.trim(),
|
name: data.name.trim(),
|
||||||
});
|
});
|
||||||
if (result?.serverError) {
|
|
||||||
toast.error(getFormattedErrorMessage(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
form.reset(data);
|
form.reset(data);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { AuthenticationError } from "@formbricks/types/errors";
|
|
||||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||||
@@ -29,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
const user = session?.user ? await getUser(session.user.id) : null;
|
const user = session?.user ? await getUser(session.user.id) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||||
@@ -61,7 +60,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
buttons={[
|
buttons={[
|
||||||
{
|
{
|
||||||
text: IS_FORMBRICKS_CLOUD
|
text: IS_FORMBRICKS_CLOUD
|
||||||
? t("common.upgrade_plan")
|
? t("common.start_free_trial")
|
||||||
: t("common.request_trial_license"),
|
: t("common.request_trial_license"),
|
||||||
href: IS_FORMBRICKS_CLOUD
|
href: IS_FORMBRICKS_CLOUD
|
||||||
? `/environments/${params.environmentId}/settings/billing`
|
? `/environments/${params.environmentId}/settings/billing`
|
||||||
|
|||||||
+5
-13
@@ -22,9 +22,8 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
loading,
|
loading,
|
||||||
}: OrganizationSettingsNavbarProps) => {
|
}: OrganizationSettingsNavbarProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isMember, isOwner, isManager } = getAccessFlags(membershipRole);
|
const { isMember, isOwner } = getAccessFlags(membershipRole);
|
||||||
const isOwnerOrManager = isOwner || isManager;
|
const isPricingDisabled = isMember;
|
||||||
const isMembershipPending = membershipRole === undefined || loading;
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
@@ -46,10 +45,7 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
label: t("common.api_keys"),
|
label: t("common.api_keys"),
|
||||||
href: `/environments/${environmentId}/settings/api-keys`,
|
href: `/environments/${environmentId}/settings/api-keys`,
|
||||||
current: pathname?.includes("/api-keys"),
|
current: pathname?.includes("/api-keys"),
|
||||||
disabled: isMembershipPending || !isOwnerOrManager,
|
hidden: !isOwner,
|
||||||
disabledMessage: isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "domain",
|
id: "domain",
|
||||||
@@ -62,18 +58,14 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
href: `/environments/${environmentId}/settings/billing`,
|
href: `/environments/${environmentId}/settings/billing`,
|
||||||
hidden: !isFormbricksCloud,
|
hidden: !isFormbricksCloud || loading,
|
||||||
current: pathname?.includes("/billing"),
|
current: pathname?.includes("/billing"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "enterprise",
|
id: "enterprise",
|
||||||
label: t("common.enterprise_license"),
|
label: t("common.enterprise_license"),
|
||||||
href: `/environments/${environmentId}/settings/enterprise`,
|
href: `/environments/${environmentId}/settings/enterprise`,
|
||||||
hidden: isFormbricksCloud,
|
hidden: isFormbricksCloud || isPricingDisabled,
|
||||||
disabled: isMembershipPending || isMember,
|
|
||||||
disabledMessage: isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
|
||||||
current: pathname?.includes("/enterprise"),
|
current: pathname?.includes("/enterprise"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { AuthenticationError } from "@formbricks/types/errors";
|
|
||||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -26,7 +25,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.session_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||||
|
|||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
|
||||||
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
|
||||||
import { Badge } from "@/modules/ui/components/badge";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
|
||||||
|
|
||||||
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
|
|
||||||
|
|
||||||
type TFeatureDefinition = {
|
|
||||||
key: TPublicLicenseFeatureKey;
|
|
||||||
labelKey: string;
|
|
||||||
docsUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: "contacts",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
|
|
||||||
docsUrl:
|
|
||||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "projects",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_projects"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "whitelabel",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
|
|
||||||
docsUrl:
|
|
||||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "removeBranding",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
|
|
||||||
docsUrl:
|
|
||||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "twoFactorAuth",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "sso",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_sso"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "saml",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_saml"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "spamProtection",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "auditLogs",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "accessControl",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "quotas",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EnterpriseLicenseFeaturesTableProps {
|
|
||||||
features: TEnterpriseLicenseFeatures;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsCard
|
|
||||||
title={t("environments.settings.enterprise.license_features_table_title")}
|
|
||||||
description={t("environments.settings.enterprise.license_features_table_description")}
|
|
||||||
noPadding>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-white">
|
|
||||||
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
|
|
||||||
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
|
|
||||||
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
|
|
||||||
<TableHead>{t("common.documentation")}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{getFeatureDefinitions(t).map((feature) => {
|
|
||||||
const value = features[feature.key];
|
|
||||||
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
|
|
||||||
let displayValue: number | string = "—";
|
|
||||||
|
|
||||||
if (typeof value === "number") {
|
|
||||||
displayValue = value;
|
|
||||||
} else if (value === null) {
|
|
||||||
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow key={feature.key} className="hover:bg-white">
|
|
||||||
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
type={isEnabled ? "success" : "gray"}
|
|
||||||
size="normal"
|
|
||||||
text={
|
|
||||||
isEnabled
|
|
||||||
? t("environments.settings.enterprise.license_features_table_enabled")
|
|
||||||
: t("environments.settings.enterprise.license_features_table_disabled")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-slate-600">{displayValue}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Link
|
|
||||||
href={feature.docsUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
|
|
||||||
{t("common.read_docs")}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</SettingsCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+7
-29
@@ -6,23 +6,22 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
|
||||||
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
||||||
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
|
||||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
import { Badge } from "@/modules/ui/components/badge";
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { SettingsCard } from "../../../components/SettingsCard";
|
import { SettingsCard } from "../../../components/SettingsCard";
|
||||||
|
|
||||||
|
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
|
||||||
|
|
||||||
interface EnterpriseLicenseStatusProps {
|
interface EnterpriseLicenseStatusProps {
|
||||||
status: TLicenseStatus;
|
status: LicenseStatus;
|
||||||
lastChecked: Date;
|
|
||||||
gracePeriodEnd?: Date;
|
gracePeriodEnd?: Date;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBadgeConfig = (
|
const getBadgeConfig = (
|
||||||
status: TLicenseStatus,
|
status: LicenseStatus,
|
||||||
t: TFunction
|
t: TFunction
|
||||||
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
|
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -30,11 +29,6 @@ const getBadgeConfig = (
|
|||||||
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
|
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
|
||||||
case "expired":
|
case "expired":
|
||||||
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
|
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
|
||||||
case "instance_mismatch":
|
|
||||||
return {
|
|
||||||
type: "error",
|
|
||||||
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
|
|
||||||
};
|
|
||||||
case "unreachable":
|
case "unreachable":
|
||||||
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
|
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
|
||||||
case "invalid_license":
|
case "invalid_license":
|
||||||
@@ -46,12 +40,10 @@ const getBadgeConfig = (
|
|||||||
|
|
||||||
export const EnterpriseLicenseStatus = ({
|
export const EnterpriseLicenseStatus = ({
|
||||||
status,
|
status,
|
||||||
lastChecked,
|
|
||||||
gracePeriodEnd,
|
gracePeriodEnd,
|
||||||
environmentId,
|
environmentId,
|
||||||
}: EnterpriseLicenseStatusProps) => {
|
}: EnterpriseLicenseStatusProps) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isRechecking, setIsRechecking] = useState(false);
|
const [isRechecking, setIsRechecking] = useState(false);
|
||||||
|
|
||||||
@@ -67,8 +59,6 @@ export const EnterpriseLicenseStatus = ({
|
|||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
if (result.data.status === "unreachable") {
|
if (result.data.status === "unreachable") {
|
||||||
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
|
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
|
||||||
} else if (result.data.status === "instance_mismatch") {
|
|
||||||
toast.error(t("environments.settings.enterprise.recheck_license_instance_mismatch"));
|
|
||||||
} else if (result.data.status === "invalid_license") {
|
} else if (result.data.status === "invalid_license") {
|
||||||
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
|
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
|
||||||
} else {
|
} else {
|
||||||
@@ -96,12 +86,7 @@ export const EnterpriseLicenseStatus = ({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
||||||
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
|
||||||
<span className="text-sm text-slate-500">
|
|
||||||
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -127,7 +112,7 @@ export const EnterpriseLicenseStatus = ({
|
|||||||
<Alert variant="warning" size="small">
|
<Alert variant="warning" size="small">
|
||||||
<AlertDescription className="overflow-visible whitespace-normal">
|
<AlertDescription className="overflow-visible whitespace-normal">
|
||||||
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
||||||
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
|
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -143,13 +128,6 @@ export const EnterpriseLicenseStatus = ({
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{status === "instance_mismatch" && (
|
|
||||||
<Alert variant="error" size="small">
|
|
||||||
<AlertDescription className="overflow-visible whitespace-normal">
|
|
||||||
{t("environments.settings.enterprise.license_instance_mismatch_description")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
||||||
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||||
<a
|
<a
|
||||||
|
|||||||
+9
-14
@@ -10,7 +10,6 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
@@ -94,19 +93,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
/>
|
/>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
{hasLicense ? (
|
{hasLicense ? (
|
||||||
<>
|
<EnterpriseLicenseStatus
|
||||||
<EnterpriseLicenseStatus
|
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
|
||||||
status={licenseState.status}
|
gracePeriodEnd={
|
||||||
lastChecked={licenseState.lastChecked}
|
licenseState.status === "unreachable"
|
||||||
gracePeriodEnd={
|
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||||
licenseState.status === "unreachable"
|
: undefined
|
||||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
}
|
||||||
: undefined
|
environmentId={params.environmentId}
|
||||||
}
|
/>
|
||||||
environmentId={params.environmentId}
|
|
||||||
/>
|
|
||||||
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||||
|
|||||||
-218
@@ -1,218 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
|
||||||
import { updateOrganizationAISettingsAction } from "./actions";
|
|
||||||
import { ZOrganizationAISettingsInput } from "./schemas";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
isInstanceAIConfigured: vi.fn(),
|
|
||||||
checkAuthorizationUpdated: vi.fn(),
|
|
||||||
deleteOrganization: vi.fn(),
|
|
||||||
getOrganization: vi.fn(),
|
|
||||||
getIsMultiOrgEnabled: vi.fn(),
|
|
||||||
getTranslate: vi.fn(),
|
|
||||||
updateOrganization: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/action-client", () => ({
|
|
||||||
authenticatedActionClient: {
|
|
||||||
inputSchema: vi.fn(() => ({
|
|
||||||
action: vi.fn((fn) => fn),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
|
||||||
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/organization/service", () => ({
|
|
||||||
deleteOrganization: mocks.deleteOrganization,
|
|
||||||
getOrganization: mocks.getOrganization,
|
|
||||||
updateOrganization: mocks.updateOrganization,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/ai/service", () => ({
|
|
||||||
isInstanceAIConfigured: mocks.isInstanceAIConfigured,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lingodotdev/server", () => ({
|
|
||||||
getTranslate: mocks.getTranslate,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
|
||||||
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
|
||||||
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const organizationId = "cm9gptbhg0000192zceq9ayuc";
|
|
||||||
|
|
||||||
describe("organization AI settings actions", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
|
|
||||||
mocks.getOrganization.mockResolvedValue({
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: false,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
});
|
|
||||||
mocks.isInstanceAIConfigured.mockReturnValue(true);
|
|
||||||
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
|
|
||||||
values ? `${key}:${JSON.stringify(values)}` : key
|
|
||||||
);
|
|
||||||
mocks.updateOrganization.mockResolvedValue({
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
});
|
|
||||||
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts AI toggle updates", () => {
|
|
||||||
expect(
|
|
||||||
ZOrganizationAISettingsInput.parse({
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
})
|
|
||||||
).toEqual({
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("passes owner and manager roles to the authorization check and updates organization settings", async () => {
|
|
||||||
const ctx = {
|
|
||||||
user: { id: "user_1", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
};
|
|
||||||
const parsedInput = {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await updateOrganizationAISettingsAction({ ctx, parsedInput } as any);
|
|
||||||
|
|
||||||
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
|
|
||||||
userId: "user_1",
|
|
||||||
organizationId,
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
schema: ZOrganizationAISettingsInput,
|
|
||||||
data: parsedInput.data,
|
|
||||||
roles: ["owner", "manager"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(mocks.getOrganization).toHaveBeenCalledWith(organizationId);
|
|
||||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, parsedInput.data);
|
|
||||||
expect(ctx.auditLoggingCtx).toMatchObject({
|
|
||||||
organizationId,
|
|
||||||
oldObject: {
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: false,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
},
|
|
||||||
newObject: {
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("propagates authorization failures so members cannot update AI settings", async () => {
|
|
||||||
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
updateOrganizationAISettingsAction({
|
|
||||||
ctx: {
|
|
||||||
user: { id: "user_member", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
},
|
|
||||||
parsedInput: {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any)
|
|
||||||
).rejects.toThrow(AuthorizationError);
|
|
||||||
|
|
||||||
expect(mocks.updateOrganization).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects enabling AI when the instance AI provider is not configured", async () => {
|
|
||||||
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
updateOrganizationAISettingsAction({
|
|
||||||
ctx: {
|
|
||||||
user: { id: "user_owner", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
},
|
|
||||||
parsedInput: {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any)
|
|
||||||
).rejects.toThrow(OperationNotAllowedError);
|
|
||||||
|
|
||||||
expect(mocks.updateOrganization).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("allows enabling AI when the instance configuration is valid", async () => {
|
|
||||||
await updateOrganizationAISettingsAction({
|
|
||||||
ctx: {
|
|
||||||
user: { id: "user_owner", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
},
|
|
||||||
parsedInput: {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("allows disabling AI when the instance configuration later becomes invalid", async () => {
|
|
||||||
mocks.getOrganization.mockResolvedValueOnce({
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
});
|
|
||||||
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
|
||||||
|
|
||||||
await updateOrganizationAISettingsAction({
|
|
||||||
ctx: {
|
|
||||||
user: { id: "user_owner", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
},
|
|
||||||
parsedInput: {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
|
|
||||||
isAISmartToolsEnabled: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+22
-144
@@ -2,44 +2,13 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||||
import type { TOrganizationRole } from "@formbricks/types/memberships";
|
|
||||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
|
||||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { ZOrganizationAISettingsInput, ZUpdateOrganizationAISettingsAction } from "./schemas";
|
|
||||||
|
|
||||||
async function updateOrganizationAction<T extends z.ZodRawShape>({
|
|
||||||
ctx,
|
|
||||||
organizationId,
|
|
||||||
schema,
|
|
||||||
data,
|
|
||||||
roles,
|
|
||||||
}: {
|
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
organizationId: string;
|
|
||||||
schema: z.ZodObject<T>;
|
|
||||||
data: z.infer<z.ZodObject<T>>;
|
|
||||||
roles: TOrganizationRole[];
|
|
||||||
}) {
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
organizationId,
|
|
||||||
access: [{ type: "organization", schema, data, roles }],
|
|
||||||
});
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
|
||||||
const oldObject = await getOrganization(organizationId);
|
|
||||||
const result = await updateOrganization(organizationId, data);
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
|
||||||
ctx.auditLoggingCtx.newObject = result;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ZUpdateOrganizationNameAction = z.object({
|
const ZUpdateOrganizationNameAction = z.object({
|
||||||
organizationId: ZId,
|
organizationId: ZId,
|
||||||
@@ -49,114 +18,26 @@ const ZUpdateOrganizationNameAction = z.object({
|
|||||||
export const updateOrganizationNameAction = authenticatedActionClient
|
export const updateOrganizationNameAction = authenticatedActionClient
|
||||||
.inputSchema(ZUpdateOrganizationNameAction)
|
.inputSchema(ZUpdateOrganizationNameAction)
|
||||||
.action(
|
.action(
|
||||||
withAuditLogging(
|
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
|
||||||
"updated",
|
await checkAuthorizationUpdated({
|
||||||
"organization",
|
userId: ctx.user.id,
|
||||||
async ({
|
organizationId: parsedInput.organizationId,
|
||||||
ctx,
|
access: [
|
||||||
parsedInput,
|
{
|
||||||
}: {
|
type: "organization",
|
||||||
ctx: AuthenticatedActionClientCtx;
|
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||||
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
|
data: parsedInput.data,
|
||||||
}) =>
|
roles: ["owner"],
|
||||||
updateOrganizationAction({
|
},
|
||||||
ctx,
|
],
|
||||||
organizationId: parsedInput.organizationId,
|
});
|
||||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||||
data: parsedInput.data,
|
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||||
roles: ["owner"],
|
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||||
})
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
)
|
ctx.auditLoggingCtx.newObject = result;
|
||||||
);
|
return result;
|
||||||
|
})
|
||||||
type TOrganizationAISettings = Pick<
|
|
||||||
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
|
|
||||||
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
|
|
||||||
>;
|
|
||||||
|
|
||||||
type TResolvedOrganizationAISettings = {
|
|
||||||
smartToolsEnabled: boolean;
|
|
||||||
dataAnalysisEnabled: boolean;
|
|
||||||
isEnablingAnyAISetting: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveOrganizationAISettings = ({
|
|
||||||
data,
|
|
||||||
organization,
|
|
||||||
}: {
|
|
||||||
data: z.infer<typeof ZOrganizationAISettingsInput>;
|
|
||||||
organization: TOrganizationAISettings;
|
|
||||||
}): TResolvedOrganizationAISettings => {
|
|
||||||
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
|
|
||||||
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
|
|
||||||
: organization.isAISmartToolsEnabled;
|
|
||||||
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
|
|
||||||
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
|
|
||||||
: organization.isAIDataAnalysisEnabled;
|
|
||||||
|
|
||||||
return {
|
|
||||||
smartToolsEnabled,
|
|
||||||
dataAnalysisEnabled,
|
|
||||||
isEnablingAnyAISetting:
|
|
||||||
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
|
|
||||||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const assertOrganizationAISettingsUpdateAllowed = ({
|
|
||||||
isInstanceAIConfigured,
|
|
||||||
resolvedSettings,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
isInstanceAIConfigured: boolean;
|
|
||||||
resolvedSettings: TResolvedOrganizationAISettings;
|
|
||||||
t: Awaited<ReturnType<typeof getTranslate>>;
|
|
||||||
}) => {
|
|
||||||
if (resolvedSettings.isEnablingAnyAISetting && !isInstanceAIConfigured) {
|
|
||||||
throw new OperationNotAllowedError(t("environments.settings.general.ai_instance_not_configured"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateOrganizationAISettingsAction = authenticatedActionClient
|
|
||||||
.inputSchema(ZUpdateOrganizationAISettingsAction)
|
|
||||||
.action(
|
|
||||||
withAuditLogging(
|
|
||||||
"updated",
|
|
||||||
"organization",
|
|
||||||
async ({
|
|
||||||
ctx,
|
|
||||||
parsedInput,
|
|
||||||
}: {
|
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
|
|
||||||
}) => {
|
|
||||||
const t = await getTranslate(ctx.user.locale);
|
|
||||||
const organization = await getOrganization(parsedInput.organizationId);
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new ResourceNotFoundError("Organization", parsedInput.organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedSettings = resolveOrganizationAISettings({
|
|
||||||
data: parsedInput.data,
|
|
||||||
organization,
|
|
||||||
});
|
|
||||||
|
|
||||||
assertOrganizationAISettingsUpdateAllowed({
|
|
||||||
isInstanceAIConfigured: isInstanceAIConfigured(),
|
|
||||||
resolvedSettings,
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
|
|
||||||
return updateOrganizationAction({
|
|
||||||
ctx,
|
|
||||||
organizationId: parsedInput.organizationId,
|
|
||||||
schema: ZOrganizationAISettingsInput,
|
|
||||||
data: parsedInput.data,
|
|
||||||
roles: ["owner", "manager"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const ZDeleteOrganizationAction = z.object({
|
const ZDeleteOrganizationAction = z.object({
|
||||||
@@ -168,10 +49,7 @@ export const deleteOrganizationAction = authenticatedActionClient
|
|||||||
.action(
|
.action(
|
||||||
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
if (!isMultiOrgEnabled) {
|
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||||
const t = await getTranslate(ctx.user.locale);
|
|
||||||
throw new OperationNotAllowedError(t("environments.settings.general.organization_deletion_disabled"));
|
|
||||||
}
|
|
||||||
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
|||||||
-118
@@ -1,118 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
|
||||||
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
|
||||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
|
||||||
|
|
||||||
interface AISettingsToggleProps {
|
|
||||||
organization: TOrganization;
|
|
||||||
membershipRole?: TOrganizationRole;
|
|
||||||
isInstanceAIConfigured: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AISettingsToggle = ({
|
|
||||||
organization,
|
|
||||||
membershipRole,
|
|
||||||
isInstanceAIConfigured,
|
|
||||||
}: Readonly<AISettingsToggleProps>) => {
|
|
||||||
const [loadingField, setLoadingField] = useState<string | null>(null);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
|
||||||
const canEdit = isOwner || isManager;
|
|
||||||
const aiEnablementState = getOrganizationAIEnablementState({
|
|
||||||
isInstanceConfigured: isInstanceAIConfigured,
|
|
||||||
});
|
|
||||||
const showInstanceConfigWarning = aiEnablementState.blockReason === "instanceNotConfigured";
|
|
||||||
const isToggleDisabled = loadingField !== null || !canEdit || !aiEnablementState.canEnableFeatures;
|
|
||||||
const aiEnablementBlockedMessage = t("environments.settings.general.ai_instance_not_configured");
|
|
||||||
const displayedSmartToolsValue = getDisplayedOrganizationAISettingValue({
|
|
||||||
currentValue: organization.isAISmartToolsEnabled,
|
|
||||||
isInstanceConfigured: isInstanceAIConfigured,
|
|
||||||
});
|
|
||||||
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
|
|
||||||
currentValue: organization.isAIDataAnalysisEnabled,
|
|
||||||
isInstanceConfigured: isInstanceAIConfigured,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleToggle = async (
|
|
||||||
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
|
|
||||||
checked: boolean
|
|
||||||
) => {
|
|
||||||
if (checked && !aiEnablementState.canEnableFeatures) {
|
|
||||||
toast.error(aiEnablementBlockedMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingField(field);
|
|
||||||
try {
|
|
||||||
const data =
|
|
||||||
field === "isAISmartToolsEnabled"
|
|
||||||
? { isAISmartToolsEnabled: checked }
|
|
||||||
: { isAIDataAnalysisEnabled: checked };
|
|
||||||
const response = await updateOrganizationAISettingsAction({
|
|
||||||
organizationId: organization.id,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response?.data) {
|
|
||||||
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
toast.error(getFormattedErrorMessage(response));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again"));
|
|
||||||
} finally {
|
|
||||||
setLoadingField(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{showInstanceConfigWarning && (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertDescription>{aiEnablementBlockedMessage}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AdvancedOptionToggle
|
|
||||||
isChecked={displayedSmartToolsValue}
|
|
||||||
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
|
|
||||||
htmlId="ai-smart-tools-toggle"
|
|
||||||
title={t("environments.settings.general.ai_smart_tools_enabled")}
|
|
||||||
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
|
|
||||||
disabled={isToggleDisabled}
|
|
||||||
customContainerClass="px-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdvancedOptionToggle
|
|
||||||
isChecked={displayedDataAnalysisValue}
|
|
||||||
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
|
|
||||||
htmlId="ai-data-analysis-toggle"
|
|
||||||
title={t("environments.settings.general.ai_data_analysis_enabled")}
|
|
||||||
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
|
|
||||||
disabled={isToggleDisabled}
|
|
||||||
customContainerClass="px-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!canEdit && (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertDescription>
|
|
||||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-11
@@ -1,5 +1,4 @@
|
|||||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
|
||||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
@@ -12,7 +11,6 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
|||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
import packageJson from "@/package.json";
|
import packageJson from "@/package.json";
|
||||||
import { SettingsCard } from "../../components/SettingsCard";
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
import { AISettingsToggle } from "./components/AISettingsToggle";
|
|
||||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||||
import { SecurityListTip } from "./components/SecurityListTip";
|
import { SecurityListTip } from "./components/SecurityListTip";
|
||||||
@@ -62,15 +60,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
membershipRole={currentUserMembership?.role}
|
membershipRole={currentUserMembership?.role}
|
||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
<SettingsCard
|
|
||||||
title={t("environments.settings.general.ai_enabled")}
|
|
||||||
description={t("environments.settings.general.ai_enabled_description")}>
|
|
||||||
<AISettingsToggle
|
|
||||||
organization={organization}
|
|
||||||
membershipRole={currentUserMembership?.role}
|
|
||||||
isInstanceAIConfigured={isInstanceAIConfigured()}
|
|
||||||
/>
|
|
||||||
</SettingsCard>
|
|
||||||
<EmailCustomizationSettings
|
<EmailCustomizationSettings
|
||||||
organization={organization}
|
organization={organization}
|
||||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||||
|
|||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
|
||||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
|
||||||
|
|
||||||
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ZUpdateOrganizationAISettingsAction = z.object({
|
|
||||||
organizationId: ZId,
|
|
||||||
data: ZOrganizationAISettingsInput,
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
@@ -18,15 +17,15 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.session_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
+4
-10
@@ -29,7 +29,6 @@ import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surv
|
|||||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||||
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
@@ -97,8 +96,8 @@ export const ResponseTable = ({
|
|||||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||||
// Generate columns
|
// Generate columns
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
|
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
||||||
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
|
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save settings to localStorage when they change
|
// Save settings to localStorage when they change
|
||||||
@@ -202,13 +201,7 @@ export const ResponseTable = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
||||||
const result = await deleteResponseAction({
|
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
|
||||||
responseId,
|
|
||||||
decrementQuotas: params?.decrementQuotas ?? false,
|
|
||||||
});
|
|
||||||
if (result?.serverError) {
|
|
||||||
throw new Error(getFormattedErrorMessage(result));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle downloading selected responses
|
// Handle downloading selected responses
|
||||||
@@ -307,6 +300,7 @@ export const ResponseTable = ({
|
|||||||
<DataTableSettingsModal
|
<DataTableSettingsModal
|
||||||
open={isTableSettingsModalOpen}
|
open={isTableSettingsModalOpen}
|
||||||
setOpen={setIsTableSettingsModalOpen}
|
setOpen={setIsTableSettingsModalOpen}
|
||||||
|
survey={survey}
|
||||||
table={table}
|
table={table}
|
||||||
columnOrder={columnOrder}
|
columnOrder={columnOrder}
|
||||||
handleDragEnd={handleDragEnd}
|
handleDragEnd={handleDragEnd}
|
||||||
|
|||||||
+3
-10
@@ -8,11 +8,10 @@ import { TResponseTableData } from "@formbricks/types/responses";
|
|||||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
@@ -35,7 +34,6 @@ const getElementColumnsData = (
|
|||||||
element: TSurveyElement,
|
element: TSurveyElement,
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
locale: TUserLocale,
|
|
||||||
t: TFunction
|
t: TFunction
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
||||||
@@ -169,7 +167,6 @@ const getElementColumnsData = (
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
locale={locale}
|
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
showId={false}
|
showId={false}
|
||||||
/>
|
/>
|
||||||
@@ -221,7 +218,6 @@ const getElementColumnsData = (
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
locale={locale}
|
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
showId={false}
|
showId={false}
|
||||||
/>
|
/>
|
||||||
@@ -263,14 +259,11 @@ export const generateResponseTableColumns = (
|
|||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
isReadOnly: boolean,
|
isReadOnly: boolean,
|
||||||
locale: TUserLocale,
|
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
showQuotasColumn: boolean
|
showQuotasColumn: boolean
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
const elements = getElementsFromBlocks(survey.blocks);
|
||||||
const elementColumns = elements.flatMap((element) =>
|
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
|
||||||
getElementColumnsData(element, survey, isExpanded, locale, t)
|
|
||||||
);
|
|
||||||
|
|
||||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
@@ -278,7 +271,7 @@ export const generateResponseTableColumns = (
|
|||||||
size: 200,
|
size: 200,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const date = new Date(row.original.createdAt);
|
const date = new Date(row.original.createdAt);
|
||||||
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
|
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+8
-7
@@ -1,4 +1,3 @@
|
|||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
@@ -8,6 +7,7 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
|
|||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -23,24 +23,25 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
|||||||
|
|
||||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
|
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
||||||
getSurvey(params.surveyId),
|
getSurvey(params.surveyId),
|
||||||
getUser(session.user.id),
|
getUser(session.user.id),
|
||||||
getTagsByEnvironmentId(params.environmentId),
|
getTagsByEnvironmentId(params.environmentId),
|
||||||
getIsContactsEnabled(organization.id),
|
getIsContactsEnabled(organization.id),
|
||||||
getResponseCountBySurveyId(params.surveyId),
|
getResponseCountBySurveyId(params.surveyId),
|
||||||
|
findMatchingLocale(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
throw new Error(t("common.survey_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||||
@@ -49,7 +50,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
|||||||
|
|
||||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
const organizationBilling = await getOrganizationBilling(organization.id);
|
||||||
if (!organizationBilling) {
|
if (!organizationBilling) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), organization.id);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
|
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
|
||||||
@@ -85,7 +86,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
|||||||
environmentTags={tags}
|
environmentTags={tags}
|
||||||
user={user}
|
user={user}
|
||||||
responsesPerPage={RESPONSES_PER_PAGE}
|
responsesPerPage={RESPONSES_PER_PAGE}
|
||||||
locale={user.locale}
|
locale={locale}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
isQuotasAllowed={isQuotasAllowed}
|
||||||
quotas={quotas}
|
quotas={quotas}
|
||||||
|
|||||||
+4
-6
@@ -64,17 +64,15 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
|||||||
|
|
||||||
const ZResetSurveyAction = z.object({
|
const ZResetSurveyAction = z.object({
|
||||||
surveyId: ZId,
|
surveyId: ZId,
|
||||||
|
organizationId: ZId,
|
||||||
projectId: ZId,
|
projectId: ZId,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
||||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
|
||||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
|
||||||
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId,
|
organizationId: parsedInput.organizationId,
|
||||||
access: [
|
access: [
|
||||||
{
|
{
|
||||||
type: "organization",
|
type: "organization",
|
||||||
@@ -83,12 +81,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
|
|||||||
{
|
{
|
||||||
type: "projectTeam",
|
type: "projectTeam",
|
||||||
minPermission: "readWrite",
|
minPermission: "readWrite",
|
||||||
projectId,
|
projectId: parsedInput.projectId,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||||
ctx.auditLoggingCtx.oldObject = null;
|
ctx.auditLoggingCtx.oldObject = null;
|
||||||
|
|
||||||
|
|||||||
+9
-10
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
|
|||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
|
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
@@ -32,14 +32,13 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderResponseValue = (value: string) => {
|
const renderResponseValue = (value: string) => {
|
||||||
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
|
const parsedDate = new Date(value);
|
||||||
|
|
||||||
return (
|
const formattedDate = isNaN(parsedDate.getTime())
|
||||||
formattedDate ??
|
? `${t("common.invalid_date")}(${value})`
|
||||||
t("common.invalid_date_with_value", {
|
: formatDateWithOrdinal(parsedDate);
|
||||||
value,
|
|
||||||
})
|
return formattedDate;
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,7 +59,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
|||||||
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||||
<div
|
<div
|
||||||
key={response.id}
|
key={response.id}
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||||
<div className="pl-4 md:pl-6">
|
<div className="pl-4 md:pl-6">
|
||||||
{response.contact ? (
|
{response.contact ? (
|
||||||
<Link
|
<Link
|
||||||
@@ -85,7 +84,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
|||||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||||
{renderResponseValue(response.value)}
|
{renderResponseValue(response.value)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 md:px-6">
|
<div className="px-4 text-slate-500 md:px-6">
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-3
@@ -107,9 +107,7 @@ export const SummaryMetadata = ({
|
|||||||
label={t("environments.surveys.summary.time_to_complete")}
|
label={t("environments.surveys.summary.time_to_complete")}
|
||||||
percentage={null}
|
percentage={null}
|
||||||
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
|
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
|
||||||
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
|
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
|
||||||
defaultValue: "Average time to complete the survey.",
|
|
||||||
})}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||||
const [isResetting, setIsResetting] = useState(false);
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
const { project } = useEnvironment();
|
const { organizationId, project } = useEnvironment();
|
||||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||||
|
|
||||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||||
@@ -128,6 +128,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
setIsResetting(true);
|
setIsResetting(true);
|
||||||
const result = await resetSurveyAction({
|
const result = await resetSurveyAction({
|
||||||
surveyId: survey.id,
|
surveyId: survey.id,
|
||||||
|
organizationId: organizationId,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
});
|
});
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
|
|||||||
+1
-2
@@ -163,10 +163,9 @@ export const PersonalLinksTab = ({
|
|||||||
<UpgradePrompt
|
<UpgradePrompt
|
||||||
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
|
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
|
||||||
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
||||||
feature="personal_links"
|
|
||||||
buttons={[
|
buttons={[
|
||||||
{
|
{
|
||||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||||
href: isFormbricksCloud
|
href: isFormbricksCloud
|
||||||
? `/environments/${environmentId}/settings/billing`
|
? `/environments/${environmentId}/settings/billing`
|
||||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||||
|
|||||||
+2
-3
@@ -1,4 +1,3 @@
|
|||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
@@ -10,11 +9,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const survey = await getSurvey(surveyId);
|
const survey = await getSurvey(surveyId);
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new ResourceNotFoundError(t("common.survey"), surveyId);
|
throw new Error("Survey not found");
|
||||||
}
|
}
|
||||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error("Workspace not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const styling = getStyling(project, survey);
|
const styling = getStyling(project, survey);
|
||||||
|
|||||||
+68
-57
@@ -11,7 +11,8 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
|||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||||
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
import {
|
import {
|
||||||
getElementSummary,
|
getElementSummary,
|
||||||
getResponsesForSummary,
|
getResponsesForSummary,
|
||||||
@@ -43,7 +44,7 @@ vi.mock("@/lib/survey/service", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("@/lib/surveyLogic/utils", () => ({
|
vi.mock("@/lib/surveyLogic/utils", () => ({
|
||||||
evaluateLogic: vi.fn(),
|
evaluateLogic: vi.fn(),
|
||||||
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
|
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/utils/validate", () => ({
|
vi.mock("@/lib/utils/validate", () => ({
|
||||||
validateInputs: vi.fn(),
|
validateInputs: vi.fn(),
|
||||||
@@ -164,7 +165,7 @@ describe("getSurveySummaryMeta", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("calculates meta correctly", () => {
|
test("calculates meta correctly", () => {
|
||||||
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
|
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
|
||||||
expect(meta.displayCount).toBe(10);
|
expect(meta.displayCount).toBe(10);
|
||||||
expect(meta.totalResponses).toBe(3);
|
expect(meta.totalResponses).toBe(3);
|
||||||
expect(meta.startsPercentage).toBe(30);
|
expect(meta.startsPercentage).toBe(30);
|
||||||
@@ -178,13 +179,13 @@ describe("getSurveySummaryMeta", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("handles zero display count", () => {
|
test("handles zero display count", () => {
|
||||||
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
|
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
|
||||||
expect(meta.startsPercentage).toBe(0);
|
expect(meta.startsPercentage).toBe(0);
|
||||||
expect(meta.completedPercentage).toBe(0);
|
expect(meta.completedPercentage).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles zero responses", () => {
|
test("handles zero responses", () => {
|
||||||
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
|
const meta = getSurveySummaryMeta([], 10, mockQuotas);
|
||||||
expect(meta.totalResponses).toBe(0);
|
expect(meta.totalResponses).toBe(0);
|
||||||
expect(meta.completedResponses).toBe(0);
|
expect(meta.completedResponses).toBe(0);
|
||||||
expect(meta.dropOffCount).toBe(0);
|
expect(meta.dropOffCount).toBe(0);
|
||||||
@@ -228,6 +229,12 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
||||||
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
||||||
);
|
);
|
||||||
|
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
|
||||||
|
vi.mocked(performActions).mockReturnValue({
|
||||||
|
jumpTarget: undefined,
|
||||||
|
requiredElementIds: [],
|
||||||
|
calculations: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("calculates dropOff correctly with welcome card disabled", () => {
|
test("calculates dropOff correctly with welcome card disabled", () => {
|
||||||
@@ -239,7 +246,7 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
contact: null,
|
contact: null,
|
||||||
contactAttributes: {},
|
contactAttributes: {},
|
||||||
language: "en",
|
language: "en",
|
||||||
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
|
ttc: { q1: 10 },
|
||||||
finished: false,
|
finished: false,
|
||||||
}, // Dropped at q2
|
}, // Dropped at q2
|
||||||
{
|
{
|
||||||
@@ -262,55 +269,22 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(dropOff.length).toBe(2);
|
expect(dropOff.length).toBe(2);
|
||||||
// Q1: welcome card disabled so impressions = displayCount
|
// Q1
|
||||||
expect(dropOff[0].elementId).toBe("q1");
|
expect(dropOff[0].elementId).toBe("q1");
|
||||||
expect(dropOff[0].impressions).toBe(displayCount);
|
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
|
||||||
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
|
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
|
||||||
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
|
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
|
||||||
expect(dropOff[0].ttc).toBe(10);
|
expect(dropOff[0].ttc).toBe(10);
|
||||||
|
|
||||||
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
|
// Q2
|
||||||
expect(dropOff[1].elementId).toBe("q2");
|
expect(dropOff[1].elementId).toBe("q2");
|
||||||
expect(dropOff[1].impressions).toBe(2);
|
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
|
||||||
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
|
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
|
||||||
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
||||||
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
|
expect(dropOff[1].ttc).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
|
test("handles logic jumps", () => {
|
||||||
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
|
|
||||||
const surveyWithWelcome: TSurvey = {
|
|
||||||
...surveyWithBlocks,
|
|
||||||
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
|
|
||||||
};
|
|
||||||
const responses = [
|
|
||||||
{
|
|
||||||
id: "r1",
|
|
||||||
data: { q1: "a" },
|
|
||||||
updatedAt: new Date(),
|
|
||||||
contact: null,
|
|
||||||
contactAttributes: {},
|
|
||||||
language: "en",
|
|
||||||
ttc: { q1: 10 }, // Only saw q1, never reached q2
|
|
||||||
finished: false,
|
|
||||||
},
|
|
||||||
] as any;
|
|
||||||
const displayCount = 1;
|
|
||||||
const dropOff = getSurveySummaryDropOff(
|
|
||||||
surveyWithWelcome,
|
|
||||||
getElementsFromBlocks(surveyWithWelcome.blocks),
|
|
||||||
responses,
|
|
||||||
displayCount
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(dropOff[0].impressions).toBe(1); // Saw q1
|
|
||||||
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
|
|
||||||
expect(dropOff[1].impressions).toBe(0); // Never saw q2
|
|
||||||
expect(dropOff[1].dropOffCount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
|
|
||||||
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
|
|
||||||
const surveyWithLogic: TSurvey = {
|
const surveyWithLogic: TSurvey = {
|
||||||
...mockBaseSurvey,
|
...mockBaseSurvey,
|
||||||
blocks: [
|
blocks: [
|
||||||
@@ -341,6 +315,36 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
charLimit: { enabled: false },
|
charLimit: { enabled: false },
|
||||||
},
|
},
|
||||||
] as TSurveyElement[],
|
] as TSurveyElement[],
|
||||||
|
logic: [
|
||||||
|
{
|
||||||
|
id: "logic1",
|
||||||
|
conditions: {
|
||||||
|
id: "condition1",
|
||||||
|
connector: "and" as const,
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
id: "c1",
|
||||||
|
leftOperand: {
|
||||||
|
type: "element" as const,
|
||||||
|
value: "q2",
|
||||||
|
},
|
||||||
|
operator: "equals" as const,
|
||||||
|
rightOperand: {
|
||||||
|
type: "static" as const,
|
||||||
|
value: "b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "action1",
|
||||||
|
objective: "jumpToBlock" as const,
|
||||||
|
target: "q4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "block3",
|
id: "block3",
|
||||||
@@ -373,21 +377,28 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
],
|
],
|
||||||
questions: [],
|
questions: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
|
|
||||||
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
|
|
||||||
const responses = [
|
const responses = [
|
||||||
{
|
{
|
||||||
id: "r1",
|
id: "r1",
|
||||||
data: { q1: "a", q2: "b", q4: "d" },
|
data: { q1: "a", q2: "b" },
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
contact: null,
|
contact: null,
|
||||||
contactAttributes: {},
|
contactAttributes: {},
|
||||||
language: "en",
|
language: "en",
|
||||||
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
|
ttc: { q1: 10, q2: 10 },
|
||||||
finished: false,
|
finished: false,
|
||||||
},
|
}, // Jumps from q2 to q4, drops at q4
|
||||||
];
|
];
|
||||||
|
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
|
||||||
|
// Simulate logic on q2 triggering
|
||||||
|
return data.q2 === "b";
|
||||||
|
});
|
||||||
|
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
|
||||||
|
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
|
||||||
|
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
|
||||||
|
}
|
||||||
|
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
|
||||||
|
});
|
||||||
|
|
||||||
const dropOff = getSurveySummaryDropOff(
|
const dropOff = getSurveySummaryDropOff(
|
||||||
surveyWithLogic,
|
surveyWithLogic,
|
||||||
@@ -396,11 +407,11 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(dropOff[0].impressions).toBe(1); // q1: seen
|
expect(dropOff[0].impressions).toBe(1); // q1
|
||||||
expect(dropOff[1].impressions).toBe(1); // q2: seen
|
expect(dropOff[1].impressions).toBe(1); // q2
|
||||||
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data)
|
expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
|
||||||
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen
|
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
|
||||||
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished)
|
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+120
-65
@@ -11,6 +11,7 @@ import {
|
|||||||
TResponseData,
|
TResponseData,
|
||||||
TResponseFilterCriteria,
|
TResponseFilterCriteria,
|
||||||
TResponseTtc,
|
TResponseTtc,
|
||||||
|
TResponseVariables,
|
||||||
ZResponseFilterCriteria,
|
ZResponseFilterCriteria,
|
||||||
} from "@formbricks/types/responses";
|
} from "@formbricks/types/responses";
|
||||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
@@ -36,7 +37,8 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
|||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { buildWhereClause } from "@/lib/response/utils";
|
import { buildWhereClause } from "@/lib/response/utils";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
|
||||||
|
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { convertFloatTo2Decimal } from "./utils";
|
import { convertFloatTo2Decimal } from "./utils";
|
||||||
|
|
||||||
@@ -51,32 +53,7 @@ interface TSurveySummaryResponse {
|
|||||||
finished: boolean;
|
finished: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
|
|
||||||
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
|
|
||||||
block.elements.forEach((element) => {
|
|
||||||
acc[element.id] = block.id;
|
|
||||||
});
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBlockTimesForResponse = (
|
|
||||||
response: TSurveySummaryResponse,
|
|
||||||
survey: TSurvey
|
|
||||||
): Record<string, number> => {
|
|
||||||
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
|
|
||||||
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
|
|
||||||
const elementTtc = response.ttc?.[element.id] ?? 0;
|
|
||||||
return Math.max(maxTtc, elementTtc);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
acc[block.id] = maxElementTtc;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSurveySummaryMeta = (
|
export const getSurveySummaryMeta = (
|
||||||
survey: TSurvey,
|
|
||||||
responses: TSurveySummaryResponse[],
|
responses: TSurveySummaryResponse[],
|
||||||
displayCount: number,
|
displayCount: number,
|
||||||
quotas: TSurveySummary["quotas"]
|
quotas: TSurveySummary["quotas"]
|
||||||
@@ -85,15 +62,9 @@ export const getSurveySummaryMeta = (
|
|||||||
|
|
||||||
let ttcResponseCount = 0;
|
let ttcResponseCount = 0;
|
||||||
const ttcSum = responses.reduce((acc, response) => {
|
const ttcSum = responses.reduce((acc, response) => {
|
||||||
const blockTimes = getBlockTimesForResponse(response, survey);
|
if (response.ttc?._total) {
|
||||||
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
|
|
||||||
|
|
||||||
// Fallback to _total for malformed surveys with no block mappings.
|
|
||||||
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
|
|
||||||
|
|
||||||
if (responseTtcTotal > 0) {
|
|
||||||
ttcResponseCount++;
|
ttcResponseCount++;
|
||||||
return acc + responseTtcTotal;
|
return acc + response.ttc._total;
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -122,13 +93,63 @@ export const getSurveySummaryMeta = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine whether a response interacted with a given element.
|
const evaluateLogicAndGetNextElementId = (
|
||||||
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer.
|
localSurvey: TSurvey,
|
||||||
// This is more reliable than replaying survey logic, which can misattribute impressions
|
elements: TSurveyElement[],
|
||||||
// when branching logic skips elements or when partial response data is insufficient
|
data: TResponseData,
|
||||||
// to evaluate conditions correctly.
|
localVariables: TResponseVariables,
|
||||||
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => {
|
currentElementIndex: number,
|
||||||
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined;
|
currElementTemp: TSurveyElement,
|
||||||
|
selectedLanguage: string | null
|
||||||
|
): {
|
||||||
|
nextElementId: string | undefined;
|
||||||
|
updatedSurvey: TSurvey;
|
||||||
|
updatedVariables: TResponseVariables;
|
||||||
|
} => {
|
||||||
|
let updatedSurvey = { ...localSurvey };
|
||||||
|
let updatedVariables = { ...localVariables };
|
||||||
|
|
||||||
|
let firstJumpTarget: string | undefined;
|
||||||
|
|
||||||
|
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
|
||||||
|
|
||||||
|
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
||||||
|
for (const logic of currentBlock.logic) {
|
||||||
|
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
||||||
|
const { jumpTarget, requiredElementIds, calculations } = performActions(
|
||||||
|
updatedSurvey,
|
||||||
|
logic.actions,
|
||||||
|
data,
|
||||||
|
updatedVariables
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requiredElementIds.length > 0) {
|
||||||
|
// Update blocks to mark elements as required
|
||||||
|
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
||||||
|
...block,
|
||||||
|
elements: block.elements.map((e) =>
|
||||||
|
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
updatedVariables = { ...updatedVariables, ...calculations };
|
||||||
|
|
||||||
|
if (jumpTarget && !firstJumpTarget) {
|
||||||
|
firstJumpTarget = jumpTarget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no jump target was set, check for a fallback logic
|
||||||
|
if (!firstJumpTarget && currentBlock?.logicFallback) {
|
||||||
|
firstJumpTarget = currentBlock.logicFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the first jump target if found, otherwise go to the next element
|
||||||
|
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
|
||||||
|
|
||||||
|
return { nextElementId, updatedSurvey, updatedVariables };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSurveySummaryDropOff = (
|
export const getSurveySummaryDropOff = (
|
||||||
@@ -148,35 +169,69 @@ export const getSurveySummaryDropOff = (
|
|||||||
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
||||||
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
||||||
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
||||||
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
|
|
||||||
|
const surveyVariablesData = survey.variables?.reduce(
|
||||||
|
(acc, variable) => {
|
||||||
|
acc[variable.id] = variable.value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string | number>
|
||||||
|
);
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
// Calculate total time-to-completion per element
|
// Calculate total time-to-completion
|
||||||
const blockTimes = getBlockTimesForResponse(response, survey);
|
|
||||||
Object.keys(totalTtc).forEach((elementId) => {
|
Object.keys(totalTtc).forEach((elementId) => {
|
||||||
const blockId = elementIdToBlockId[elementId];
|
if (response.ttc && response.ttc[elementId]) {
|
||||||
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
|
totalTtc[elementId] += response.ttc[elementId];
|
||||||
if (blockTtc > 0) {
|
|
||||||
totalTtc[elementId] += blockTtc;
|
|
||||||
responseCounts[elementId]++;
|
responseCounts[elementId]++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Count impressions based on actual interaction data (ttc + response data)
|
let localSurvey = structuredClone(survey);
|
||||||
// instead of replaying survey logic which is unreliable with branching
|
let localResponseData: TResponseData = { ...response.data };
|
||||||
let lastSeenIdx = -1;
|
let localVariables: TResponseVariables = {
|
||||||
|
...surveyVariablesData,
|
||||||
|
};
|
||||||
|
|
||||||
for (let i = 0; i < elements.length; i++) {
|
let currQuesIdx = 0;
|
||||||
const element = elements[i];
|
|
||||||
if (wasElementSeen(response, element.id)) {
|
while (currQuesIdx < elements.length) {
|
||||||
impressionsArr[i]++;
|
const currQues = elements[currQuesIdx];
|
||||||
lastSeenIdx = i;
|
if (!currQues) break;
|
||||||
|
|
||||||
|
// element is not answered and required
|
||||||
|
if (response.data[currQues.id] === undefined && currQues.required) {
|
||||||
|
dropOffArr[currQuesIdx]++;
|
||||||
|
impressionsArr[currQuesIdx]++;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Attribute drop-off to the last element the respondent interacted with
|
impressionsArr[currQuesIdx]++;
|
||||||
if (!response.finished && lastSeenIdx >= 0) {
|
|
||||||
dropOffArr[lastSeenIdx]++;
|
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
|
||||||
|
localSurvey,
|
||||||
|
elements,
|
||||||
|
localResponseData,
|
||||||
|
localVariables,
|
||||||
|
currQuesIdx,
|
||||||
|
currQues,
|
||||||
|
response.language
|
||||||
|
);
|
||||||
|
|
||||||
|
localSurvey = updatedSurvey;
|
||||||
|
localVariables = updatedVariables;
|
||||||
|
|
||||||
|
if (nextElementId) {
|
||||||
|
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
|
||||||
|
if (!response.data[nextElementId] && !response.finished) {
|
||||||
|
dropOffArr[nextQuesIdx]++;
|
||||||
|
impressionsArr[nextQuesIdx]++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currQuesIdx = nextQuesIdx;
|
||||||
|
} else {
|
||||||
|
currQuesIdx++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,8 +240,6 @@ export const getSurveySummaryDropOff = (
|
|||||||
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// When the welcome card is disabled, the first element's impressions should equal displayCount
|
|
||||||
// because every survey display is an impression of the first element
|
|
||||||
if (!survey.welcomeCard.enabled) {
|
if (!survey.welcomeCard.enabled) {
|
||||||
dropOffArr[0] = displayCount - impressionsArr[0];
|
dropOffArr[0] = displayCount - impressionsArr[0];
|
||||||
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
|
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
|
||||||
@@ -198,7 +251,7 @@ export const getSurveySummaryDropOff = (
|
|||||||
|
|
||||||
impressionsArr[0] = displayCount;
|
impressionsArr[0] = displayCount;
|
||||||
} else {
|
} else {
|
||||||
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
|
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 1; i < elements.length; i++) {
|
for (let i = 1; i < elements.length; i++) {
|
||||||
@@ -1009,8 +1062,10 @@ export const getSurveySummary = reactCache(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
||||||
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
|
const [meta, elementSummary] = await Promise.all([
|
||||||
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
|
getSurveySummaryMeta(responses, displayCount, quotas),
|
||||||
|
getElementSummary(survey, elements, responses, dropOff),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta,
|
meta,
|
||||||
|
|||||||
+4
-5
@@ -1,5 +1,4 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
@@ -33,13 +32,13 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
const survey = await getSurvey(params.surveyId);
|
const survey = await getSurvey(params.surveyId);
|
||||||
|
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
throw new Error(t("common.survey_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||||
|
|
||||||
@@ -47,11 +46,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
|
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||||
if (!organizationBilling) {
|
if (!organizationBilling) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), organizationId);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,21 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
import { ZSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
|
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||||
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||||
|
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||||
|
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||||
|
|
||||||
const ZGetResponsesDownloadUrlAction = z.object({
|
const ZGetResponsesDownloadUrlAction = z.object({
|
||||||
@@ -24,11 +28,9 @@ const ZGetResponsesDownloadUrlAction = z.object({
|
|||||||
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||||
.inputSchema(ZGetResponsesDownloadUrlAction)
|
.inputSchema(ZGetResponsesDownloadUrlAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
|
||||||
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId,
|
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||||
access: [
|
access: [
|
||||||
{
|
{
|
||||||
type: "organization",
|
type: "organization",
|
||||||
@@ -42,20 +44,11 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await getResponseDownloadFile(
|
return await getResponseDownloadFile(
|
||||||
parsedInput.surveyId,
|
parsedInput.surveyId,
|
||||||
parsedInput.format,
|
parsedInput.format,
|
||||||
parsedInput.filterCriteria
|
parsedInput.filterCriteria
|
||||||
);
|
);
|
||||||
|
|
||||||
capturePostHogEvent(ctx.user.id, "responses_exported", {
|
|
||||||
survey_id: parsedInput.surveyId,
|
|
||||||
format: parsedInput.format,
|
|
||||||
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
|
|
||||||
organization_id: organizationId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ZGetSurveyFilterDataAction = z.object({
|
const ZGetSurveyFilterDataAction = z.object({
|
||||||
@@ -104,3 +97,68 @@ export const getSurveyFilterDataAction = authenticatedActionClient
|
|||||||
|
|
||||||
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
|
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if survey follow-ups are enabled for the given organization.
|
||||||
|
*
|
||||||
|
* @param {string} organizationId The ID of the organization to check.
|
||||||
|
* @returns {Promise<void>} A promise that resolves if the permission is granted.
|
||||||
|
* @throws {ResourceNotFoundError} If the organization is not found.
|
||||||
|
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
|
||||||
|
*/
|
||||||
|
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
|
||||||
|
const organization = await getOrganization(organizationId);
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
|
||||||
|
if (!isSurveyFollowUpsEnabled) {
|
||||||
|
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
|
||||||
|
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||||
|
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user?.id ?? "",
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
projectId: await getProjectIdFromSurveyId(parsedInput.id),
|
||||||
|
minPermission: "readWrite",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { followUps } = parsedInput;
|
||||||
|
|
||||||
|
const oldSurvey = await getSurvey(parsedInput.id);
|
||||||
|
|
||||||
|
if (parsedInput.recaptcha?.enabled) {
|
||||||
|
await checkSpamProtectionPermission(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (followUps?.length) {
|
||||||
|
await checkSurveyFollowUpsPermission(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context for audit log
|
||||||
|
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
||||||
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
|
ctx.auditLoggingCtx.oldObject = oldSurvey;
|
||||||
|
|
||||||
|
const newSurvey = await updateSurvey(parsedInput);
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||||
|
|
||||||
|
return newSurvey;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|||||||
+1
-26
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { TFunction } from "i18next";
|
|
||||||
import {
|
import {
|
||||||
AirplayIcon,
|
AirplayIcon,
|
||||||
ArrowUpFromDotIcon,
|
ArrowUpFromDotIcon,
|
||||||
@@ -55,25 +54,6 @@ export enum OptionsType {
|
|||||||
QUOTAS = "Quotas",
|
QUOTAS = "Quotas",
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
|
|
||||||
switch (type) {
|
|
||||||
case OptionsType.ELEMENTS:
|
|
||||||
return t("common.elements");
|
|
||||||
case OptionsType.TAGS:
|
|
||||||
return t("common.tags");
|
|
||||||
case OptionsType.ATTRIBUTES:
|
|
||||||
return t("common.attributes");
|
|
||||||
case OptionsType.OTHERS:
|
|
||||||
return t("common.other_filters");
|
|
||||||
case OptionsType.META:
|
|
||||||
return t("common.meta");
|
|
||||||
case OptionsType.HIDDEN_FIELDS:
|
|
||||||
return t("common.hidden_fields");
|
|
||||||
case OptionsType.QUOTAS:
|
|
||||||
return t("common.quotas");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ElementOption = {
|
export type ElementOption = {
|
||||||
label: string;
|
label: string;
|
||||||
elementType?: TSurveyElementTypeEnum;
|
elementType?: TSurveyElementTypeEnum;
|
||||||
@@ -238,12 +218,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
|||||||
{options?.map((data) => (
|
{options?.map((data) => (
|
||||||
<Fragment key={data.header}>
|
<Fragment key={data.header}>
|
||||||
{data?.option.length > 0 && (
|
{data?.option.length > 0 && (
|
||||||
<CommandGroup
|
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||||
heading={
|
|
||||||
<p className="text-sm font-medium text-slate-600">
|
|
||||||
{getOptionsTypeTranslationKey(data.header, t)}
|
|
||||||
</p>
|
|
||||||
}>
|
|
||||||
{data?.option?.map((o) => (
|
{data?.option?.map((o) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={o.id}
|
key={o.id}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -15,6 +14,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/modules/ui/components/select";
|
} from "@/modules/ui/components/select";
|
||||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||||
|
import { updateSurveyAction } from "../actions";
|
||||||
|
|
||||||
interface SurveyStatusDropdownProps {
|
interface SurveyStatusDropdownProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { SurveyContextWrapper } from "./context/survey-context";
|
import { SurveyContextWrapper } from "./context/survey-context";
|
||||||
|
|
||||||
interface SurveyLayoutProps {
|
interface SurveyLayoutProps {
|
||||||
@@ -12,10 +10,9 @@ const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
|
|||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
|
|
||||||
const survey = await getSurvey(resolvedParams.surveyId);
|
const survey = await getSurvey(resolvedParams.surveyId);
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new ResourceNotFoundError(t("common.survey"), resolvedParams.surveyId);
|
throw new Error("Survey not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
|
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
|
||||||
|
|||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckCircle2, Sparkles } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
|
||||||
|
const FORMBRICKS_HOST = "https://app.formbricks.com";
|
||||||
|
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
|
||||||
|
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
|
||||||
|
|
||||||
|
interface WorkflowsPageProps {
|
||||||
|
userEmail: string;
|
||||||
|
organizationName: string;
|
||||||
|
billingPlan: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = "prompt" | "followup" | "thankyou";
|
||||||
|
|
||||||
|
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [step, setStep] = useState<Step>("prompt");
|
||||||
|
const [promptValue, setPromptValue] = useState("");
|
||||||
|
const [detailsValue, setDetailsValue] = useState("");
|
||||||
|
const [responseId, setResponseId] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleGenerateWorkflow = async () => {
|
||||||
|
if (promptValue.trim().length < 100 || isSubmitting) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
surveyId: SURVEY_ID,
|
||||||
|
finished: false,
|
||||||
|
data: {
|
||||||
|
workflow: promptValue.trim(),
|
||||||
|
useremail: userEmail,
|
||||||
|
orgname: organizationName,
|
||||||
|
billingplan: billingPlan,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
setResponseId(json.data?.id ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep("followup");
|
||||||
|
} catch {
|
||||||
|
setStep("followup");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitFeedback = async () => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
if (responseId) {
|
||||||
|
try {
|
||||||
|
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
finished: true,
|
||||||
|
data: {
|
||||||
|
details: detailsValue.trim(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setStep("thankyou");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkipFeedback = async () => {
|
||||||
|
if (!responseId) {
|
||||||
|
setStep("thankyou");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
finished: true,
|
||||||
|
data: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep("thankyou");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step === "prompt") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||||
|
<div className="w-full max-w-2xl space-y-8">
|
||||||
|
<div className="space-y-3 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
|
||||||
|
<Sparkles className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
|
||||||
|
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
value={promptValue}
|
||||||
|
onChange={(e) => setPromptValue(e.target.value)}
|
||||||
|
placeholder={t("workflows.placeholder")}
|
||||||
|
rows={5}
|
||||||
|
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
handleGenerateWorkflow();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
|
||||||
|
{promptValue.trim().length} / 100
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerateWorkflow}
|
||||||
|
disabled={promptValue.trim().length < 100 || isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
size="lg">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
{t("workflows.generate_button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "followup") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||||
|
<div className="w-full max-w-2xl space-y-8">
|
||||||
|
<div className="space-y-3 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
|
||||||
|
<Sparkles className="h-6 w-6 text-brand-dark" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
|
||||||
|
{t("workflows.coming_soon_title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mx-auto max-w-md text-base text-slate-500">
|
||||||
|
{t("workflows.coming_soon_description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<label className="text-md mb-2 block font-medium text-slate-700">
|
||||||
|
{t("workflows.follow_up_label")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={detailsValue}
|
||||||
|
onChange={(e) => setDetailsValue(e.target.value)}
|
||||||
|
placeholder={t("workflows.follow_up_placeholder")}
|
||||||
|
rows={4}
|
||||||
|
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-3">
|
||||||
|
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
|
||||||
|
{t("common.skip")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmitFeedback}
|
||||||
|
disabled={!detailsValue.trim() || isSubmitting}
|
||||||
|
loading={isSubmitting}>
|
||||||
|
{t("workflows.submit_button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||||
|
<div className="w-full max-w-md space-y-6 text-center">
|
||||||
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
|
||||||
|
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
|
||||||
|
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
|
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { WorkflowsPage } from "./components/workflows-page";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Workflows",
|
||||||
|
};
|
||||||
|
|
||||||
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
if (!IS_FORMBRICKS_CLOUD) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
if (isBilling) {
|
||||||
|
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUser(session.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkflowsPage
|
||||||
|
userEmail={user.email}
|
||||||
|
organizationName={organization.name}
|
||||||
|
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
|||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { ZIntegrationInput } from "@formbricks/types/integration";
|
import { ZIntegrationInput } from "@formbricks/types/integration";
|
||||||
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
|
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
|
||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import {
|
import {
|
||||||
@@ -46,12 +45,6 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
|
|||||||
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
||||||
ctx.auditLoggingCtx.integrationId = result.id;
|
ctx.auditLoggingCtx.integrationId = result.id;
|
||||||
ctx.auditLoggingCtx.newObject = result;
|
ctx.auditLoggingCtx.newObject = result;
|
||||||
|
|
||||||
capturePostHogEvent(ctx.user.id, "integration_connected", {
|
|
||||||
integration_type: parsedInput.integrationData.type,
|
|
||||||
organization_id: organizationId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
+8
-6
@@ -4,9 +4,9 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
|||||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { getAirtableTables } from "@/lib/airtable/service";
|
import { getAirtableTables } from "@/lib/airtable/service";
|
||||||
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
@@ -18,12 +18,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
||||||
|
|
||||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [surveys, integrations, locale] = await Promise.all([
|
const [surveys, integrations] = await Promise.all([
|
||||||
getSurveys(params.environmentId),
|
getSurveys(params.environmentId),
|
||||||
getIntegrations(params.environmentId),
|
getIntegrations(params.environmentId),
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
||||||
@@ -34,6 +33,9 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
if (airtableIntegration?.config.key) {
|
if (airtableIntegration?.config.key) {
|
||||||
airtableArray = await getAirtableTables(params.environmentId);
|
airtableArray = await getAirtableTables(params.environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
return redirect("./");
|
||||||
}
|
}
|
||||||
@@ -50,7 +52,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
webAppUrl={WEBAPP_URL}
|
webAppUrl={WEBAPP_URL}
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
|
|||||||
+7
-6
@@ -3,14 +3,13 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-s
|
|||||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCALE,
|
|
||||||
GOOGLE_SHEETS_CLIENT_ID,
|
GOOGLE_SHEETS_CLIENT_ID,
|
||||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||||
GOOGLE_SHEETS_REDIRECT_URL,
|
GOOGLE_SHEETS_REDIRECT_URL,
|
||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
@@ -22,17 +21,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
||||||
|
|
||||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [surveys, integrations, locale] = await Promise.all([
|
const [surveys, integrations] = await Promise.all([
|
||||||
getSurveys(params.environmentId),
|
getSurveys(params.environmentId),
|
||||||
getIntegrations(params.environmentId),
|
getIntegrations(params.environmentId),
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
|
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
|
||||||
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
|
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
return redirect("./");
|
||||||
}
|
}
|
||||||
@@ -48,7 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
googleSheetIntegration={googleSheetIntegration}
|
googleSheetIntegration={googleSheetIntegration}
|
||||||
webAppUrl={WEBAPP_URL}
|
webAppUrl={WEBAPP_URL}
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
|
|||||||
+5
-6
@@ -3,7 +3,6 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
|
|||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
|
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCALE,
|
|
||||||
NOTION_AUTH_URL,
|
NOTION_AUTH_URL,
|
||||||
NOTION_OAUTH_CLIENT_ID,
|
NOTION_OAUTH_CLIENT_ID,
|
||||||
NOTION_OAUTH_CLIENT_SECRET,
|
NOTION_OAUTH_CLIENT_SECRET,
|
||||||
@@ -12,7 +11,7 @@ import {
|
|||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { getIntegrationByType } from "@/lib/integration/service";
|
import { getIntegrationByType } from "@/lib/integration/service";
|
||||||
import { getNotionDatabases } from "@/lib/notion/service";
|
import { getNotionDatabases } from "@/lib/notion/service";
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
@@ -29,18 +28,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
NOTION_REDIRECT_URI
|
NOTION_REDIRECT_URI
|
||||||
);
|
);
|
||||||
|
|
||||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [surveys, notionIntegration, locale] = await Promise.all([
|
const [surveys, notionIntegration] = await Promise.all([
|
||||||
getSurveys(params.environmentId),
|
getSurveys(params.environmentId),
|
||||||
getIntegrationByType(params.environmentId, "notion"),
|
getIntegrationByType(params.environmentId, "notion"),
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let databasesArray: TIntegrationNotionDatabase[] = [];
|
let databasesArray: TIntegrationNotionDatabase[] = [];
|
||||||
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
|
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
|
||||||
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
|
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
|
||||||
}
|
}
|
||||||
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
return redirect("./");
|
||||||
@@ -57,7 +56,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
notionIntegration={notionIntegration as TIntegrationNotion}
|
notionIntegration={notionIntegration as TIntegrationNotion}
|
||||||
webAppUrl={WEBAPP_URL}
|
webAppUrl={WEBAPP_URL}
|
||||||
databasesArray={databasesArray}
|
databasesArray={databasesArray}
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ import notionLogo from "@/images/notion.png";
|
|||||||
import SlackLogo from "@/images/slacklogo.png";
|
import SlackLogo from "@/images/slacklogo.png";
|
||||||
import WebhookLogo from "@/images/webhook.png";
|
import WebhookLogo from "@/images/webhook.png";
|
||||||
import ZapierLogo from "@/images/zapier-small.png";
|
import ZapierLogo from "@/images/zapier-small.png";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||||
@@ -55,7 +53,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
integrations.some((integration) => integration.type === type);
|
integrations.some((integration) => integration.type === type);
|
||||||
|
|
||||||
if (isBilling) {
|
if (isBilling) {
|
||||||
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
|
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
|
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
|
||||||
|
|||||||
+7
-6
@@ -2,9 +2,9 @@ import { redirect } from "next/navigation";
|
|||||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
|
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
|
||||||
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getIntegrationByType } from "@/lib/integration/service";
|
import { getIntegrationByType } from "@/lib/integration/service";
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
@@ -17,14 +17,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
|
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [surveys, slackIntegration, locale] = await Promise.all([
|
const [surveys, slackIntegration] = await Promise.all([
|
||||||
getSurveys(params.environmentId),
|
getSurveys(params.environmentId),
|
||||||
getIntegrationByType(params.environmentId, "slack"),
|
getIntegrationByType(params.environmentId, "slack"),
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
return redirect("./");
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
slackIntegration={slackIntegration as TIntegrationSlack}
|
slackIntegration={slackIntegration as TIntegrationSlack}
|
||||||
webAppUrl={WEBAPP_URL}
|
webAppUrl={WEBAPP_URL}
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import {
|
|||||||
CHATWOOT_WEBSITE_TOKEN,
|
CHATWOOT_WEBSITE_TOKEN,
|
||||||
IS_CHATWOOT_CONFIGURED,
|
IS_CHATWOOT_CONFIGURED,
|
||||||
POSTHOG_KEY,
|
POSTHOG_KEY,
|
||||||
SESSION_MAX_AGE,
|
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { NextAuthProvider } from "@/modules/auth/components/next-auth-provider";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||||
@@ -25,7 +23,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider sessionMaxAge={SESSION_MAX_AGE}>
|
<>
|
||||||
<NoMobileOverlay />
|
<NoMobileOverlay />
|
||||||
{POSTHOG_KEY && user && (
|
{POSTHOG_KEY && user && (
|
||||||
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
|
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
|
||||||
@@ -41,7 +39,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
|||||||
)}
|
)}
|
||||||
<ToasterClient />
|
<ToasterClient />
|
||||||
{children}
|
{children}
|
||||||
</NextAuthProvider>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
|
||||||
|
|
||||||
interface SurveyResponsePostHogEventParams {
|
|
||||||
organizationId: string;
|
|
||||||
surveyId: string;
|
|
||||||
surveyType: string;
|
|
||||||
environmentId: string;
|
|
||||||
responseCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Captures a PostHog event for survey responses at milestones:
|
|
||||||
* 1st response, then every 100th (100, 200, 300, ...).
|
|
||||||
*/
|
|
||||||
export const captureSurveyResponsePostHogEvent = ({
|
|
||||||
organizationId,
|
|
||||||
surveyId,
|
|
||||||
surveyType,
|
|
||||||
environmentId,
|
|
||||||
responseCount,
|
|
||||||
}: SurveyResponsePostHogEventParams): void => {
|
|
||||||
if (responseCount !== 1 && responseCount % 100 !== 0) return;
|
|
||||||
|
|
||||||
capturePostHogEvent(organizationId, "survey_response_received", {
|
|
||||||
survey_id: surveyId,
|
|
||||||
survey_type: surveyType,
|
|
||||||
organization_id: organizationId,
|
|
||||||
environment_id: environmentId,
|
|
||||||
response_count: responseCount,
|
|
||||||
is_first_response: responseCount === 1,
|
|
||||||
milestone: responseCount === 1 ? "first" : String(responseCount),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -50,21 +50,8 @@ vi.mock("@/lib/env", () => ({
|
|||||||
RECAPTCHA_SITE_KEY: "site-key",
|
RECAPTCHA_SITE_KEY: "site-key",
|
||||||
RECAPTCHA_SECRET_KEY: "secret-key",
|
RECAPTCHA_SECRET_KEY: "secret-key",
|
||||||
GITHUB_ID: "github-id",
|
GITHUB_ID: "github-id",
|
||||||
SAML_DATABASE_URL: "postgresql://saml.example.com/formbricks",
|
|
||||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
E2E_TESTING: false,
|
|
||||||
IS_DEVELOPMENT: false,
|
|
||||||
TELEMETRY_DISABLED: false,
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/hash-string", () => ({
|
|
||||||
hashString: vi.fn((s: string) => `hashed-${s}`),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock fetch
|
// Mock fetch
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
@@ -151,7 +138,6 @@ describe("sendTelemetryEvents", () => {
|
|||||||
expect(payload.userCount).toBe(5);
|
expect(payload.userCount).toBe(5);
|
||||||
expect(payload.integrations.notion).toBe(true);
|
expect(payload.integrations.notion).toBe(true);
|
||||||
expect(payload.sso.github).toBe(true);
|
expect(payload.sso.github).toBe(true);
|
||||||
expect(payload.sso.saml).toBe(true);
|
|
||||||
|
|
||||||
// Check cache update (no TTL parameter)
|
// Check cache update (no TTL parameter)
|
||||||
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String));
|
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String));
|
||||||
@@ -211,14 +197,6 @@ describe("sendTelemetryEvents", () => {
|
|||||||
test("should handle telemetry send failure and apply cooldown", async () => {
|
test("should handle telemetry send failure and apply cooldown", async () => {
|
||||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
// Reset module to clear nextTelemetryCheck state from previous tests
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.doMock("@/lib/constants", () => ({
|
|
||||||
E2E_TESTING: false,
|
|
||||||
IS_DEVELOPMENT: false,
|
|
||||||
TELEMETRY_DISABLED: false,
|
|
||||||
}));
|
|
||||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
|
|
||||||
}));
|
|
||||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||||
|
|
||||||
// Ensure we can acquire lock by setting last sent time far in the past
|
// Ensure we can acquire lock by setting last sent time far in the past
|
||||||
@@ -241,7 +219,6 @@ describe("sendTelemetryEvents", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
error: networkError,
|
error: networkError,
|
||||||
message: "Network error",
|
message: "Network error",
|
||||||
hashedLicenseKey: "hashed-test-license-key",
|
|
||||||
}),
|
}),
|
||||||
"Failed to send telemetry - applying 1h cooldown"
|
"Failed to send telemetry - applying 1h cooldown"
|
||||||
);
|
);
|
||||||
@@ -263,14 +240,6 @@ describe("sendTelemetryEvents", () => {
|
|||||||
test("should skip if no organization exists", async () => {
|
test("should skip if no organization exists", async () => {
|
||||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
// Reset module to clear nextTelemetryCheck state from previous tests
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.doMock("@/lib/constants", () => ({
|
|
||||||
E2E_TESTING: false,
|
|
||||||
IS_DEVELOPMENT: false,
|
|
||||||
TELEMETRY_DISABLED: false,
|
|
||||||
}));
|
|
||||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
|
|
||||||
}));
|
|
||||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||||
|
|
||||||
// Ensure we can acquire lock by setting last sent time far in the past
|
// Ensure we can acquire lock by setting last sent time far in the past
|
||||||
@@ -305,113 +274,4 @@ describe("sendTelemetryEvents", () => {
|
|||||||
// This might be a bug, but we test the actual behavior
|
// This might be a bug, but we test the actual behavior
|
||||||
expect(mockCacheService.set).toHaveBeenCalled();
|
expect(mockCacheService.set).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should skip telemetry when TELEMETRY_DISABLED is true and no active EE license", async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doMock("@/lib/constants", () => ({
|
|
||||||
E2E_TESTING: false,
|
|
||||||
IS_DEVELOPMENT: false,
|
|
||||||
TELEMETRY_DISABLED: true,
|
|
||||||
}));
|
|
||||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
|
|
||||||
}));
|
|
||||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
|
||||||
|
|
||||||
await freshSendTelemetryEvents();
|
|
||||||
|
|
||||||
// Should return early without touching cache or sending telemetry
|
|
||||||
expect(getCacheService).not.toHaveBeenCalled();
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should send telemetry when TELEMETRY_DISABLED is true but EE license is active", async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doMock("@/lib/constants", () => ({
|
|
||||||
E2E_TESTING: false,
|
|
||||||
IS_DEVELOPMENT: false,
|
|
||||||
TELEMETRY_DISABLED: true,
|
|
||||||
}));
|
|
||||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
|
|
||||||
}));
|
|
||||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
|
||||||
|
|
||||||
// Re-setup mocks after resetModules
|
|
||||||
vi.mocked(getCacheService).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
data: mockCacheService as any,
|
|
||||||
});
|
|
||||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true });
|
|
||||||
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
|
|
||||||
mockCacheService.get.mockResolvedValue({ ok: true, data: null });
|
|
||||||
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
|
|
||||||
|
|
||||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
|
|
||||||
id: "org-123",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
} as any);
|
|
||||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
|
||||||
{
|
|
||||||
organizationCount: BigInt(1),
|
|
||||||
userCount: BigInt(5),
|
|
||||||
teamCount: BigInt(2),
|
|
||||||
projectCount: BigInt(3),
|
|
||||||
surveyCount: BigInt(10),
|
|
||||||
inProgressSurveyCount: BigInt(4),
|
|
||||||
completedSurveyCount: BigInt(6),
|
|
||||||
responseCountAllTime: BigInt(100),
|
|
||||||
responseCountSinceLastUpdate: BigInt(10),
|
|
||||||
displayCount: BigInt(50),
|
|
||||||
contactCount: BigInt(20),
|
|
||||||
segmentCount: BigInt(4),
|
|
||||||
newestResponseAt: new Date("2024-01-01T00:00:00.000Z"),
|
|
||||||
},
|
|
||||||
] as any);
|
|
||||||
vi.mocked(prisma.integration.findMany).mockResolvedValue([{ type: IntegrationType.notion }] as any);
|
|
||||||
vi.mocked(prisma.account.findMany).mockResolvedValue([{ provider: "github" }] as any);
|
|
||||||
fetchMock.mockResolvedValue({ ok: true });
|
|
||||||
|
|
||||||
await freshSendTelemetryEvents();
|
|
||||||
|
|
||||||
// EE license active — telemetry should bypass TELEMETRY_DISABLED and send
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should unconditionally skip when E2E_TESTING is true even with active EE license", async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doMock("@/lib/constants", () => ({
|
|
||||||
E2E_TESTING: true,
|
|
||||||
IS_DEVELOPMENT: false,
|
|
||||||
TELEMETRY_DISABLED: false,
|
|
||||||
}));
|
|
||||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
|
|
||||||
}));
|
|
||||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
|
||||||
|
|
||||||
await freshSendTelemetryEvents();
|
|
||||||
|
|
||||||
// E2E_TESTING is a hard skip — no EE bypass, no cache, no fetch
|
|
||||||
expect(getCacheService).not.toHaveBeenCalled();
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should unconditionally skip when IS_DEVELOPMENT is true", async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
vi.doMock("@/lib/constants", () => ({
|
|
||||||
E2E_TESTING: false,
|
|
||||||
IS_DEVELOPMENT: true,
|
|
||||||
TELEMETRY_DISABLED: false,
|
|
||||||
}));
|
|
||||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
|
|
||||||
}));
|
|
||||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
|
||||||
|
|
||||||
await freshSendTelemetryEvents();
|
|
||||||
|
|
||||||
expect(getCacheService).not.toHaveBeenCalled();
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,8 @@ import { IntegrationType } from "@prisma/client";
|
|||||||
import { createCacheKey, getCacheService } from "@formbricks/cache";
|
import { createCacheKey, getCacheService } from "@formbricks/cache";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { E2E_TESTING, IS_DEVELOPMENT, TELEMETRY_DISABLED } from "@/lib/constants";
|
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import { hashString } from "@/lib/hash-string";
|
|
||||||
import { getInstanceInfo } from "@/lib/instance";
|
import { getInstanceInfo } from "@/lib/instance";
|
||||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
|
||||||
import packageJson from "@/package.json";
|
import packageJson from "@/package.json";
|
||||||
|
|
||||||
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
@@ -27,31 +24,8 @@ let nextTelemetryCheck = 0;
|
|||||||
* 2. Redis check (shared across instances, persists across restarts)
|
* 2. Redis check (shared across instances, persists across restarts)
|
||||||
* 3. Distributed lock (prevents concurrent execution in multi-instance deployments)
|
* 3. Distributed lock (prevents concurrent execution in multi-instance deployments)
|
||||||
*/
|
*/
|
||||||
// Hashed license key for log context — allows correlating log entries to a specific license
|
|
||||||
// without exposing the raw key. Computed once at module load.
|
|
||||||
const hashedLicenseKey = env.ENTERPRISE_LICENSE_KEY ? hashString(env.ENTERPRISE_LICENSE_KEY) : null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if telemetry is disabled via env var AND there is no active EE license.
|
|
||||||
* EE customers cannot opt out — telemetry is always enforced for license compliance.
|
|
||||||
*/
|
|
||||||
const isTelemetryDisabledForCE = async (): Promise<boolean> => {
|
|
||||||
if (!TELEMETRY_DISABLED) return false;
|
|
||||||
const license = await getEnterpriseLicense();
|
|
||||||
return !license.active;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const sendTelemetryEvents = async () => {
|
export const sendTelemetryEvents = async () => {
|
||||||
try {
|
try {
|
||||||
// ============================================================
|
|
||||||
// CHECK 0: Non-Production Hard Skip
|
|
||||||
// ============================================================
|
|
||||||
// Purpose: Unconditionally skip telemetry in dev and test/CI environments.
|
|
||||||
// No EE bypass — these are internal flags, not customer-facing.
|
|
||||||
if (E2E_TESTING || IS_DEVELOPMENT) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -65,18 +39,7 @@ export const sendTelemetryEvents = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// CHECK 2: Telemetry Disabled Check
|
// CHECK 2: Redis Check (Shared State)
|
||||||
// ============================================================
|
|
||||||
// Purpose: Allow CE self-hosters to opt out of telemetry via env var.
|
|
||||||
// EE bypass: If an active Enterprise License is detected, telemetry is always sent
|
|
||||||
// regardless of the TELEMETRY_DISABLED setting to enforce license compliance.
|
|
||||||
// Placed after in-memory check to avoid calling getEnterpriseLicense() on every invocation.
|
|
||||||
if (await isTelemetryDisabledForCE()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// CHECK 3: Redis Check (Shared State)
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Purpose: Check if telemetry was sent recently by ANY instance (shared across cluster).
|
// Purpose: Check if telemetry was sent recently by ANY instance (shared across cluster).
|
||||||
// This persists across restarts and works in multi-instance deployments.
|
// This persists across restarts and works in multi-instance deployments.
|
||||||
@@ -103,7 +66,7 @@ export const sendTelemetryEvents = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// CHECK 4: Distributed Lock (Prevent Concurrent Execution)
|
// CHECK 3: Distributed Lock (Prevent Concurrent Execution)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Purpose: Ensure only ONE instance executes telemetry at a time in a cluster.
|
// Purpose: Ensure only ONE instance executes telemetry at a time in a cluster.
|
||||||
// How it works:
|
// How it works:
|
||||||
@@ -137,7 +100,7 @@ export const sendTelemetryEvents = async () => {
|
|||||||
// Log as warning since telemetry is non-essential
|
// Log as warning since telemetry is non-essential
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ error: e, message: errorMessage, lastSent, now, hashedLicenseKey },
|
{ error: e, message: errorMessage, lastSent, now },
|
||||||
"Failed to send telemetry - applying 1h cooldown"
|
"Failed to send telemetry - applying 1h cooldown"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -155,7 +118,7 @@ export const sendTelemetryEvents = async () => {
|
|||||||
// Log as warning since telemetry is non-essential functionality
|
// Log as warning since telemetry is non-essential functionality
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ error, message: errorMessage, timestamp: Date.now(), hashedLicenseKey },
|
{ error, message: errorMessage, timestamp: Date.now() },
|
||||||
"Unexpected error in sendTelemetryEvents wrapper - telemetry check skipped"
|
"Unexpected error in sendTelemetryEvents wrapper - telemetry check skipped"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -249,7 +212,6 @@ const sendTelemetry = async (lastSent: number) => {
|
|||||||
google: !!env.GOOGLE_CLIENT_ID || ssoProviders.some((p) => p.provider === "google"),
|
google: !!env.GOOGLE_CLIENT_ID || ssoProviders.some((p) => p.provider === "google"),
|
||||||
azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"),
|
azureAd: !!env.AZUREAD_CLIENT_ID || ssoProviders.some((p) => p.provider === "azuread"),
|
||||||
oidc: !!env.OIDC_CLIENT_ID || ssoProviders.some((p) => p.provider === "openid"),
|
oidc: !!env.OIDC_CLIENT_ID || ssoProviders.some((p) => p.provider === "openid"),
|
||||||
saml: !!env.SAML_DATABASE_URL || ssoProviders.some((p) => p.provider === "saml"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Construct telemetry payload with usage statistics and configuration.
|
// Construct telemetry payload with usage statistics and configuration.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
|
|||||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
|
import { CRON_SECRET } from "@/lib/constants";
|
||||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
@@ -24,7 +24,6 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
|||||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||||
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
|
|
||||||
|
|
||||||
export const POST = async (request: Request) => {
|
export const POST = async (request: Request) => {
|
||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
@@ -300,18 +299,6 @@ export const POST = async (request: Request) => {
|
|||||||
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
|
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
|
||||||
});
|
});
|
||||||
|
|
||||||
if (POSTHOG_KEY) {
|
|
||||||
const responseCount = await getResponseCountBySurveyId(surveyId);
|
|
||||||
|
|
||||||
captureSurveyResponsePostHogEvent({
|
|
||||||
organizationId: organization.id,
|
|
||||||
surveyId,
|
|
||||||
surveyType: survey.type,
|
|
||||||
environmentId,
|
|
||||||
responseCount,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send telemetry events
|
// Send telemetry events
|
||||||
await sendTelemetryEvents();
|
await sendTelemetryEvents();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { GET } from "./route";
|
|
||||||
|
|
||||||
type WrappedAuthOptions = {
|
|
||||||
callbacks: {
|
|
||||||
signIn: (params: { user: unknown; account: unknown }) => Promise<boolean | string>;
|
|
||||||
};
|
|
||||||
events: {
|
|
||||||
signIn: (params: { user: unknown; account: unknown; isNewUser: boolean }) => Promise<void>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => {
|
|
||||||
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
|
|
||||||
const nextAuth = vi.fn((_authOptions: WrappedAuthOptions) => nextAuthHandler);
|
|
||||||
|
|
||||||
return {
|
|
||||||
nextAuth,
|
|
||||||
nextAuthHandler,
|
|
||||||
baseSignIn: vi.fn(async () => true),
|
|
||||||
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
|
|
||||||
baseEventSignIn: vi.fn(),
|
|
||||||
queueAuditEventBackground: vi.fn(),
|
|
||||||
captureException: vi.fn(),
|
|
||||||
loggerError: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("next-auth", () => ({
|
|
||||||
default: mocks.nextAuth,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
IS_PRODUCTION: false,
|
|
||||||
SENTRY_DSN: undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@sentry/nextjs", () => ({
|
|
||||||
captureException: mocks.captureException,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@formbricks/logger", () => ({
|
|
||||||
logger: {
|
|
||||||
withContext: vi.fn(() => ({
|
|
||||||
error: mocks.loggerError,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
|
||||||
authOptions: {
|
|
||||||
callbacks: {
|
|
||||||
signIn: mocks.baseSignIn,
|
|
||||||
session: mocks.baseSession,
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
signIn: mocks.baseEventSignIn,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
|
||||||
queueAuditEventBackground: mocks.queueAuditEventBackground,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const getWrappedAuthOptions = async (requestId: string = "req-123"): Promise<WrappedAuthOptions> => {
|
|
||||||
const request = new Request("http://localhost/api/auth/signin", {
|
|
||||||
headers: { "x-request-id": requestId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await GET(request, {} as any);
|
|
||||||
|
|
||||||
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
const firstCall = mocks.nextAuth.mock.calls.at(0);
|
|
||||||
if (!firstCall) {
|
|
||||||
throw new Error("NextAuth was not called");
|
|
||||||
}
|
|
||||||
|
|
||||||
const [authOptions] = firstCall;
|
|
||||||
if (!authOptions) {
|
|
||||||
throw new Error("NextAuth options were not provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
return authOptions;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("auth route audit logging", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
|
|
||||||
const authOptions = await getWrappedAuthOptions();
|
|
||||||
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
|
|
||||||
const account = { provider: "keycloak" };
|
|
||||||
|
|
||||||
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
|
|
||||||
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await authOptions.events.signIn({ user, account, isNewUser: false });
|
|
||||||
|
|
||||||
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
|
|
||||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
action: "signedIn",
|
|
||||||
targetType: "user",
|
|
||||||
userId: "user_1",
|
|
||||||
targetId: "user_1",
|
|
||||||
organizationId: "unknown",
|
|
||||||
status: "success",
|
|
||||||
userType: "user",
|
|
||||||
newObject: expect.objectContaining({
|
|
||||||
email: "user@example.com",
|
|
||||||
authMethod: "sso",
|
|
||||||
provider: "keycloak",
|
|
||||||
sessionStrategy: "database",
|
|
||||||
isNewUser: false,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
|
|
||||||
const error = new Error("Access denied");
|
|
||||||
mocks.baseSignIn.mockRejectedValueOnce(error);
|
|
||||||
|
|
||||||
const authOptions = await getWrappedAuthOptions("req-failure");
|
|
||||||
const user = { id: "user_2", email: "user2@example.com" };
|
|
||||||
const account = { provider: "credentials" };
|
|
||||||
|
|
||||||
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
|
|
||||||
|
|
||||||
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
action: "signedIn",
|
|
||||||
targetType: "user",
|
|
||||||
userId: "user_2",
|
|
||||||
targetId: "user_2",
|
|
||||||
organizationId: "unknown",
|
|
||||||
status: "failure",
|
|
||||||
userType: "user",
|
|
||||||
eventId: "req-failure",
|
|
||||||
newObject: expect.objectContaining({
|
|
||||||
email: "user2@example.com",
|
|
||||||
authMethod: "password",
|
|
||||||
provider: "credentials",
|
|
||||||
errorMessage: "Access denied",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logs blocked SSO account-linking attempts as SSO failures", async () => {
|
|
||||||
const error = new Error("OAuthAccountNotLinked");
|
|
||||||
mocks.baseSignIn.mockRejectedValueOnce(error);
|
|
||||||
|
|
||||||
const authOptions = await getWrappedAuthOptions("req-sso-failure");
|
|
||||||
const user = { id: "user_3", email: "user3@example.com" };
|
|
||||||
const account = { provider: "google" };
|
|
||||||
|
|
||||||
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("OAuthAccountNotLinked");
|
|
||||||
|
|
||||||
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
action: "signedIn",
|
|
||||||
targetType: "user",
|
|
||||||
userId: "user_3",
|
|
||||||
targetId: "user_3",
|
|
||||||
organizationId: "unknown",
|
|
||||||
status: "failure",
|
|
||||||
userType: "user",
|
|
||||||
eventId: "req-sso-failure",
|
|
||||||
newObject: expect.objectContaining({
|
|
||||||
email: "user3@example.com",
|
|
||||||
authMethod: "sso",
|
|
||||||
provider: "google",
|
|
||||||
errorMessage: "OAuthAccountNotLinked",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,26 +6,10 @@ import { logger } from "@formbricks/logger";
|
|||||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||||
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||||
|
|
||||||
export const fetchCache = "force-no-store";
|
export const fetchCache = "force-no-store";
|
||||||
|
|
||||||
const getAuthMethod = (account: Account | null) => {
|
|
||||||
if (account?.provider === "credentials") {
|
|
||||||
return "password";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account?.provider === "token") {
|
|
||||||
return "email_verification";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account?.provider) {
|
|
||||||
return "sso";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "unknown";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handler = async (req: Request, ctx: any) => {
|
const handler = async (req: Request, ctx: any) => {
|
||||||
const eventId = req.headers.get("x-request-id") ?? undefined;
|
const eventId = req.headers.get("x-request-id") ?? undefined;
|
||||||
|
|
||||||
@@ -33,6 +17,44 @@ const handler = async (req: Request, ctx: any) => {
|
|||||||
...baseAuthOptions,
|
...baseAuthOptions,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
...baseAuthOptions.callbacks,
|
...baseAuthOptions.callbacks,
|
||||||
|
async jwt(params: any) {
|
||||||
|
let result: any = params.token;
|
||||||
|
let error: any = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (baseAuthOptions.callbacks?.jwt) {
|
||||||
|
result = await baseAuthOptions.callbacks.jwt(params);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
logger.withContext({ eventId, err }).error("JWT callback failed");
|
||||||
|
|
||||||
|
if (SENTRY_DSN && IS_PRODUCTION) {
|
||||||
|
Sentry.captureException(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit JWT operations (token refresh, updates)
|
||||||
|
if (params.trigger && params.token?.profile?.id) {
|
||||||
|
const status: TAuditStatus = error ? "failure" : "success";
|
||||||
|
const auditLog = {
|
||||||
|
action: "jwtTokenCreated" as const,
|
||||||
|
targetType: "user" as const,
|
||||||
|
userId: params.token.profile.id,
|
||||||
|
targetId: params.token.profile.id,
|
||||||
|
organizationId: UNKNOWN_DATA,
|
||||||
|
status,
|
||||||
|
userType: "user" as const,
|
||||||
|
newObject: { trigger: params.trigger, tokenType: "jwt" },
|
||||||
|
...(error ? { eventId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
queueAuditEventBackground(auditLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
async session(params: any) {
|
async session(params: any) {
|
||||||
let result: any = params.session;
|
let result: any = params.session;
|
||||||
let error: any = undefined;
|
let error: any = undefined;
|
||||||
@@ -68,7 +90,7 @@ const handler = async (req: Request, ctx: any) => {
|
|||||||
}) {
|
}) {
|
||||||
let result: boolean | string = true;
|
let result: boolean | string = true;
|
||||||
let error: any = undefined;
|
let error: any = undefined;
|
||||||
const authMethod = getAuthMethod(account);
|
let authMethod = "unknown";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (baseAuthOptions.callbacks?.signIn) {
|
if (baseAuthOptions.callbacks?.signIn) {
|
||||||
@@ -80,6 +102,15 @@ const handler = async (req: Request, ctx: any) => {
|
|||||||
credentials,
|
credentials,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine authentication method for more detailed logging
|
||||||
|
if (account?.provider === "credentials") {
|
||||||
|
authMethod = "password";
|
||||||
|
} else if (account?.provider === "token") {
|
||||||
|
authMethod = "email_verification";
|
||||||
|
} else if (account?.provider && account.provider !== "credentials") {
|
||||||
|
authMethod = "sso";
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err;
|
error = err;
|
||||||
result = false;
|
result = false;
|
||||||
@@ -91,58 +122,28 @@ const handler = async (req: Request, ctx: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result === false) {
|
const status: TAuditStatus = result === false ? "failure" : "success";
|
||||||
queueAuditEventBackground({
|
const auditLog = {
|
||||||
action: "signedIn",
|
action: "signedIn" as const,
|
||||||
targetType: "user",
|
targetType: "user" as const,
|
||||||
userId: user?.id ?? UNKNOWN_DATA,
|
|
||||||
targetId: user?.id ?? UNKNOWN_DATA,
|
|
||||||
organizationId: UNKNOWN_DATA,
|
|
||||||
status: "failure",
|
|
||||||
userType: "user",
|
|
||||||
newObject: {
|
|
||||||
...user,
|
|
||||||
authMethod,
|
|
||||||
provider: account?.provider,
|
|
||||||
...(error instanceof Error ? { errorMessage: error.message } : {}),
|
|
||||||
},
|
|
||||||
eventId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
...baseAuthOptions.events,
|
|
||||||
async signIn({ user, account, isNewUser }: any) {
|
|
||||||
try {
|
|
||||||
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
|
|
||||||
} catch (err) {
|
|
||||||
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
|
|
||||||
|
|
||||||
if (SENTRY_DSN && IS_PRODUCTION) {
|
|
||||||
Sentry.captureException(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
queueAuditEventBackground({
|
|
||||||
action: "signedIn",
|
|
||||||
targetType: "user",
|
|
||||||
userId: user?.id ?? UNKNOWN_DATA,
|
userId: user?.id ?? UNKNOWN_DATA,
|
||||||
targetId: user?.id ?? UNKNOWN_DATA,
|
targetId: user?.id ?? UNKNOWN_DATA,
|
||||||
organizationId: UNKNOWN_DATA,
|
organizationId: UNKNOWN_DATA,
|
||||||
status: "success",
|
status,
|
||||||
userType: "user",
|
userType: "user" as const,
|
||||||
newObject: {
|
newObject: {
|
||||||
...user,
|
...user,
|
||||||
authMethod: getAuthMethod(account),
|
authMethod,
|
||||||
provider: account?.provider,
|
provider: account?.provider,
|
||||||
sessionStrategy: "database",
|
...(error ? { errorMessage: error.message } : {}),
|
||||||
isNewUser: isNewUser ?? false,
|
|
||||||
},
|
},
|
||||||
});
|
...(status === "failure" ? { eventId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
queueAuditEventBackground(auditLog);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { google } from "googleapis";
|
import { google } from "googleapis";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
|
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +10,6 @@ import {
|
|||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
export const GET = async (req: Request) => {
|
export const GET = async (req: Request) => {
|
||||||
@@ -85,16 +82,6 @@ export const GET = async (req: Request) => {
|
|||||||
|
|
||||||
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
||||||
if (result) {
|
if (result) {
|
||||||
try {
|
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
|
||||||
capturePostHogEvent(session.user.id, "integration_connected", {
|
|
||||||
integration_type: "googleSheets",
|
|
||||||
organization_id: organizationId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.redirect(
|
return Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||||
);
|
);
|
||||||
|
|||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
|
||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
|
||||||
|
|
||||||
export const getResponseIdByDisplayId = async (
|
|
||||||
environmentId: string,
|
|
||||||
displayId: string
|
|
||||||
): Promise<{ responseId: string | null }> => {
|
|
||||||
validateInputs([environmentId, ZId], [displayId, ZId]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const display = await prisma.display.findFirst({
|
|
||||||
where: {
|
|
||||||
id: displayId,
|
|
||||||
survey: {
|
|
||||||
environmentId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
response: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!display) {
|
|
||||||
throw new ResourceNotFoundError("Display", displayId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
responseId: display.response?.id ?? null,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
||||||
throw new DatabaseError(error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
|
||||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
|
||||||
import { getResponseIdByDisplayId } from "./lib/response";
|
|
||||||
|
|
||||||
export const OPTIONS = async (): Promise<Response> => {
|
|
||||||
return responses.successResponse({}, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GET = withV1ApiWrapper({
|
|
||||||
handler: async ({
|
|
||||||
req,
|
|
||||||
props,
|
|
||||||
}: THandlerParams<{ params: Promise<{ environmentId: string; displayId: string }> }>) => {
|
|
||||||
const params = await props.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await getResponseIdByDisplayId(params.environmentId, params.displayId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
response: responses.successResponse(response, true),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ResourceNotFoundError) {
|
|
||||||
return {
|
|
||||||
response: responses.notFoundResponse("Display", params.displayId, true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error(
|
|
||||||
{ error, url: req.url, environmentId: params.environmentId, displayId: params.displayId },
|
|
||||||
"Error in GET /api/v1/client/[environmentId]/displays/[displayId]/response"
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
+1
-42
@@ -6,7 +6,6 @@ import { TJsEnvironmentState, TJsEnvironmentStateProject } from "@formbricks/typ
|
|||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { cache } from "@/lib/cache";
|
import { cache } from "@/lib/cache";
|
||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
|
||||||
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
|
||||||
import { getEnvironmentState } from "./environmentState";
|
import { getEnvironmentState } from "./environmentState";
|
||||||
|
|
||||||
@@ -37,11 +36,6 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_RECAPTCHA_CONFIGURED: true,
|
IS_RECAPTCHA_CONFIGURED: true,
|
||||||
IS_PRODUCTION: true,
|
IS_PRODUCTION: true,
|
||||||
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
|
||||||
POSTHOG_KEY: "phc_test_key",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/posthog", () => ({
|
|
||||||
capturePostHogEvent: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock @formbricks/cache
|
// Mock @formbricks/cache
|
||||||
@@ -82,8 +76,7 @@ const mockOrganization: TOrganization = {
|
|||||||
},
|
},
|
||||||
usageCycleAnchor: new Date(),
|
usageCycleAnchor: new Date(),
|
||||||
},
|
},
|
||||||
isAISmartToolsEnabled: false,
|
isAIEnabled: false,
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSurveys: TSurvey[] = [
|
const mockSurveys: TSurvey[] = [
|
||||||
@@ -309,38 +302,4 @@ describe("getEnvironmentState", () => {
|
|||||||
|
|
||||||
expect(result.data.actionClasses).toEqual([]);
|
expect(result.data.actionClasses).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should capture app_connected PostHog event when app setup completes", async () => {
|
|
||||||
const noCodeAction = {
|
|
||||||
...mockActionClasses[0],
|
|
||||||
id: "action-2",
|
|
||||||
type: "noCode" as const,
|
|
||||||
key: null,
|
|
||||||
};
|
|
||||||
const incompleteEnvironmentData = {
|
|
||||||
...mockEnvironmentStateData,
|
|
||||||
environment: {
|
|
||||||
...mockEnvironmentStateData.environment,
|
|
||||||
appSetupCompleted: false,
|
|
||||||
},
|
|
||||||
actionClasses: [...mockActionClasses, noCodeAction],
|
|
||||||
};
|
|
||||||
vi.mocked(getEnvironmentStateData).mockResolvedValue(incompleteEnvironmentData);
|
|
||||||
|
|
||||||
await getEnvironmentState(environmentId);
|
|
||||||
|
|
||||||
expect(capturePostHogEvent).toHaveBeenCalledWith(environmentId, "app_connected", {
|
|
||||||
num_surveys: 1,
|
|
||||||
num_code_actions: 1,
|
|
||||||
num_no_code_actions: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not capture app_connected event when app setup already completed", async () => {
|
|
||||||
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
|
|
||||||
|
|
||||||
await getEnvironmentState(environmentId);
|
|
||||||
|
|
||||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { createCacheKey } from "@formbricks/cache";
|
|||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { TJsEnvironmentState } from "@formbricks/types/js";
|
import { TJsEnvironmentState } from "@formbricks/types/js";
|
||||||
import { cache } from "@/lib/cache";
|
import { cache } from "@/lib/cache";
|
||||||
import { IS_RECAPTCHA_CONFIGURED, POSTHOG_KEY, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
import { IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
|
||||||
import { getEnvironmentStateData } from "./data";
|
import { getEnvironmentStateData } from "./data";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,14 +30,6 @@ export const getEnvironmentState = async (
|
|||||||
where: { id: environmentId },
|
where: { id: environmentId },
|
||||||
data: { appSetupCompleted: true },
|
data: { appSetupCompleted: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (POSTHOG_KEY) {
|
|
||||||
capturePostHogEvent(environmentId, "app_connected", {
|
|
||||||
num_surveys: surveys.length,
|
|
||||||
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
|
|
||||||
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the response data
|
// Build the response data
|
||||||
|
|||||||
@@ -86,11 +86,9 @@ export const GET = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = err instanceof Error ? err : new Error(String(err));
|
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
{
|
{
|
||||||
error,
|
error: err,
|
||||||
url: req.url,
|
url: req.url,
|
||||||
environmentId: params.environmentId,
|
environmentId: params.environmentId,
|
||||||
},
|
},
|
||||||
@@ -98,10 +96,9 @@ export const GET = withV1ApiWrapper({
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
response: responses.internalServerErrorResponse(
|
response: responses.internalServerErrorResponse(
|
||||||
"An error occurred while processing your request.",
|
err instanceof Error ? err.message : "Unknown error occurred",
|
||||||
true
|
true
|
||||||
),
|
),
|
||||||
error,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
-488
@@ -1,488 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
|
||||||
import { putResponseHandler } from "./put-response-handler";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
|
|
||||||
getResponse: vi.fn(),
|
|
||||||
getSurvey: vi.fn(),
|
|
||||||
getValidatedResponseUpdateInput: vi.fn(),
|
|
||||||
loggerError: vi.fn(),
|
|
||||||
sendToPipeline: vi.fn(),
|
|
||||||
updateResponseWithQuotaEvaluation: vi.fn(),
|
|
||||||
validateFileUploads: vi.fn(),
|
|
||||||
validateOtherOptionLengthForMultipleChoice: vi.fn(),
|
|
||||||
validateResponseData: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@formbricks/logger", () => ({
|
|
||||||
logger: {
|
|
||||||
error: mocks.loggerError,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/lib/pipelines", () => ({
|
|
||||||
sendToPipeline: mocks.sendToPipeline,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/response/service", () => ({
|
|
||||||
getResponse: mocks.getResponse,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/survey/service", () => ({
|
|
||||||
getSurvey: mocks.getSurvey,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/api/lib/validation", () => ({
|
|
||||||
formatValidationErrorsForV1Api: mocks.formatValidationErrorsForV1Api,
|
|
||||||
validateResponseData: mocks.validateResponseData,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/api/v2/lib/element", () => ({
|
|
||||||
validateOtherOptionLengthForMultipleChoice: mocks.validateOtherOptionLengthForMultipleChoice,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/storage/utils", () => ({
|
|
||||||
validateFileUploads: mocks.validateFileUploads,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./response", () => ({
|
|
||||||
updateResponseWithQuotaEvaluation: mocks.updateResponseWithQuotaEvaluation,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./validated-response-update-input", () => ({
|
|
||||||
getValidatedResponseUpdateInput: mocks.getValidatedResponseUpdateInput,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const environmentId = "environment_a";
|
|
||||||
const responseId = "response_123";
|
|
||||||
const surveyId = "survey_123";
|
|
||||||
|
|
||||||
const createRequest = () =>
|
|
||||||
new Request(`https://api.test/api/v1/client/${environmentId}/responses/${responseId}`, {
|
|
||||||
method: "PUT",
|
|
||||||
});
|
|
||||||
|
|
||||||
const createHandlerParams = (params?: Partial<{ environmentId: string; responseId: string }>) =>
|
|
||||||
({
|
|
||||||
req: createRequest(),
|
|
||||||
props: {
|
|
||||||
params: Promise.resolve({
|
|
||||||
environmentId,
|
|
||||||
responseId,
|
|
||||||
...params,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}) as never;
|
|
||||||
|
|
||||||
const getBaseResponseUpdateInput = () => ({
|
|
||||||
data: {
|
|
||||||
q1: "updated-answer",
|
|
||||||
},
|
|
||||||
language: "en",
|
|
||||||
});
|
|
||||||
|
|
||||||
const getBaseExistingResponse = () =>
|
|
||||||
({
|
|
||||||
id: responseId,
|
|
||||||
surveyId,
|
|
||||||
data: {
|
|
||||||
q0: "existing-answer",
|
|
||||||
},
|
|
||||||
finished: false,
|
|
||||||
language: "en",
|
|
||||||
}) as const;
|
|
||||||
|
|
||||||
const getBaseSurvey = () =>
|
|
||||||
({
|
|
||||||
id: surveyId,
|
|
||||||
environmentId,
|
|
||||||
blocks: [],
|
|
||||||
questions: [],
|
|
||||||
}) as const;
|
|
||||||
|
|
||||||
const getBaseUpdatedResponse = () =>
|
|
||||||
({
|
|
||||||
id: responseId,
|
|
||||||
surveyId,
|
|
||||||
data: {
|
|
||||||
q0: "existing-answer",
|
|
||||||
q1: "updated-answer",
|
|
||||||
},
|
|
||||||
finished: false,
|
|
||||||
quotaFull: undefined,
|
|
||||||
}) as const;
|
|
||||||
|
|
||||||
describe("putResponseHandler", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
mocks.getValidatedResponseUpdateInput.mockResolvedValue({
|
|
||||||
responseUpdateInput: getBaseResponseUpdateInput(),
|
|
||||||
});
|
|
||||||
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
|
|
||||||
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
|
|
||||||
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
|
|
||||||
mocks.validateFileUploads.mockReturnValue(true);
|
|
||||||
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
|
|
||||||
mocks.validateResponseData.mockReturnValue(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns a bad request response when the response id is missing", async () => {
|
|
||||||
const result = await putResponseHandler(createHandlerParams({ responseId: "" }));
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(400);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "bad_request",
|
|
||||||
message: "Response ID is missing",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
expect(mocks.getValidatedResponseUpdateInput).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns the validation response from the parsed request input", async () => {
|
|
||||||
const validationResponse = responses.badRequestResponse(
|
|
||||||
"Malformed JSON in request body",
|
|
||||||
undefined,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
mocks.getValidatedResponseUpdateInput.mockResolvedValue({
|
|
||||||
response: validationResponse,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response).toBe(validationResponse);
|
|
||||||
expect(mocks.getResponse).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns not found when the response does not exist", async () => {
|
|
||||||
mocks.getResponse.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(404);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "not_found",
|
|
||||||
message: "Response not found",
|
|
||||||
details: {
|
|
||||||
resource_id: responseId,
|
|
||||||
resource_type: "Response",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps resource lookup errors to a not found response", async () => {
|
|
||||||
mocks.getResponse.mockRejectedValue(new ResourceNotFoundError("Response", responseId));
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(404);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "not_found",
|
|
||||||
message: "Response not found",
|
|
||||||
details: {
|
|
||||||
resource_id: responseId,
|
|
||||||
resource_type: "Response",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps invalid lookup input errors to a bad request response", async () => {
|
|
||||||
mocks.getResponse.mockRejectedValue(new InvalidInputError("Invalid response id"));
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(400);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "bad_request",
|
|
||||||
message: "Invalid response id",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps database lookup errors to a reported internal server error", async () => {
|
|
||||||
const error = new DatabaseError("Lookup failed");
|
|
||||||
mocks.getResponse.mockRejectedValue(error);
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.error).toBe(error);
|
|
||||||
expect(result.response.status).toBe(500);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "internal_server_error",
|
|
||||||
message: "Lookup failed",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
expect(mocks.loggerError).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
error,
|
|
||||||
url: createRequest().url,
|
|
||||||
},
|
|
||||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps unknown lookup failures to a generic internal server error", async () => {
|
|
||||||
const error = new Error("boom");
|
|
||||||
mocks.getResponse.mockRejectedValue(error);
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.error).toBe(error);
|
|
||||||
expect(result.response.status).toBe(500);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "internal_server_error",
|
|
||||||
message: "Unknown error occurred",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects updates when the response survey does not belong to the requested environment", async () => {
|
|
||||||
mocks.getSurvey.mockResolvedValue({
|
|
||||||
...getBaseSurvey(),
|
|
||||||
environmentId: "different_environment",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(404);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "not_found",
|
|
||||||
message: "Response not found",
|
|
||||||
details: {
|
|
||||||
resource_id: responseId,
|
|
||||||
resource_type: "Response",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects updates when the response is already finished", async () => {
|
|
||||||
mocks.getResponse.mockResolvedValue({
|
|
||||||
...getBaseExistingResponse(),
|
|
||||||
finished: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(400);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "bad_request",
|
|
||||||
message: "Response is already finished",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects invalid file upload updates", async () => {
|
|
||||||
mocks.validateFileUploads.mockReturnValue(false);
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(400);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "bad_request",
|
|
||||||
message: "Invalid file upload response",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects updates when an other-option response exceeds the character limit", async () => {
|
|
||||||
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue("question_123");
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(400);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "bad_request",
|
|
||||||
message: "Response exceeds character limit",
|
|
||||||
details: {
|
|
||||||
questionId: "question_123",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns validation details when merged response data is invalid", async () => {
|
|
||||||
mocks.validateResponseData.mockReturnValue([{ field: "q1", message: "Required" }]);
|
|
||||||
mocks.formatValidationErrorsForV1Api.mockReturnValue({
|
|
||||||
q1: "Required",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(400);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "bad_request",
|
|
||||||
message: "Validation failed",
|
|
||||||
details: {
|
|
||||||
q1: "Required",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(mocks.formatValidationErrorsForV1Api).toHaveBeenCalledWith([{ field: "q1", message: "Required" }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns not found when the response disappears during update", async () => {
|
|
||||||
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(
|
|
||||||
new ResourceNotFoundError("Response", responseId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(404);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "not_found",
|
|
||||||
message: "Response not found",
|
|
||||||
details: {
|
|
||||||
resource_id: responseId,
|
|
||||||
resource_type: "Response",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns a bad request response for invalid update input during persistence", async () => {
|
|
||||||
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(
|
|
||||||
new InvalidInputError("Response update payload is invalid")
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(400);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "bad_request",
|
|
||||||
message: "Response update payload is invalid",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns a reported internal server error for database update failures", async () => {
|
|
||||||
const error = new DatabaseError("Update failed");
|
|
||||||
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(error);
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.error).toBe(error);
|
|
||||||
expect(result.response.status).toBe(500);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "internal_server_error",
|
|
||||||
message: "Update failed",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
expect(mocks.loggerError).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
error,
|
|
||||||
url: createRequest().url,
|
|
||||||
},
|
|
||||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns a generic internal server error for unexpected update failures", async () => {
|
|
||||||
const error = new Error("Unexpected persistence failure");
|
|
||||||
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(error);
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.error).toBe(error);
|
|
||||||
expect(result.response.status).toBe(500);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
code: "internal_server_error",
|
|
||||||
message: "Something went wrong",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
expect(mocks.loggerError).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
error,
|
|
||||||
url: createRequest().url,
|
|
||||||
},
|
|
||||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns a success payload and emits a responseUpdated pipeline event", async () => {
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(200);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
data: {
|
|
||||||
id: responseId,
|
|
||||||
quotaFull: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(mocks.sendToPipeline).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.sendToPipeline).toHaveBeenCalledWith({
|
|
||||||
event: "responseUpdated",
|
|
||||||
environmentId,
|
|
||||||
surveyId,
|
|
||||||
response: {
|
|
||||||
id: responseId,
|
|
||||||
surveyId,
|
|
||||||
data: {
|
|
||||||
q0: "existing-answer",
|
|
||||||
q1: "updated-answer",
|
|
||||||
},
|
|
||||||
finished: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("emits both pipeline events and includes quota metadata when the response finishes", async () => {
|
|
||||||
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue({
|
|
||||||
...getBaseUpdatedResponse(),
|
|
||||||
finished: true,
|
|
||||||
quotaFull: {
|
|
||||||
id: "quota_123",
|
|
||||||
action: "endSurvey",
|
|
||||||
endingCardId: "ending_card_123",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await putResponseHandler(createHandlerParams());
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(200);
|
|
||||||
await expect(result.response.json()).resolves.toEqual({
|
|
||||||
data: {
|
|
||||||
id: responseId,
|
|
||||||
quotaFull: true,
|
|
||||||
quota: {
|
|
||||||
id: "quota_123",
|
|
||||||
action: "endSurvey",
|
|
||||||
endingCardId: "ending_card_123",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(mocks.sendToPipeline).toHaveBeenCalledTimes(2);
|
|
||||||
expect(mocks.sendToPipeline).toHaveBeenNthCalledWith(1, {
|
|
||||||
event: "responseUpdated",
|
|
||||||
environmentId,
|
|
||||||
surveyId,
|
|
||||||
response: {
|
|
||||||
id: responseId,
|
|
||||||
surveyId,
|
|
||||||
data: {
|
|
||||||
q0: "existing-answer",
|
|
||||||
q1: "updated-answer",
|
|
||||||
},
|
|
||||||
finished: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(mocks.sendToPipeline).toHaveBeenNthCalledWith(2, {
|
|
||||||
event: "responseFinished",
|
|
||||||
environmentId,
|
|
||||||
surveyId,
|
|
||||||
response: {
|
|
||||||
id: responseId,
|
|
||||||
surveyId,
|
|
||||||
data: {
|
|
||||||
q0: "existing-answer",
|
|
||||||
q1: "updated-answer",
|
|
||||||
},
|
|
||||||
finished: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-283
@@ -1,283 +0,0 @@
|
|||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { TResponse, TResponseUpdateInput } from "@formbricks/types/responses";
|
|
||||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
|
||||||
import { THandlerParams } from "@/app/lib/api/with-api-logging";
|
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
|
||||||
import { getResponse } from "@/lib/response/service";
|
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
|
||||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
|
||||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
|
||||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
|
||||||
import { validateFileUploads } from "@/modules/storage/utils";
|
|
||||||
import { updateResponseWithQuotaEvaluation } from "./response";
|
|
||||||
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
|
|
||||||
|
|
||||||
type TRouteResult = {
|
|
||||||
response: Response;
|
|
||||||
error?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TExistingResponseResult = { existingResponse: TResponse } | TRouteResult;
|
|
||||||
type TSurveyResult = { survey: TSurvey } | TRouteResult;
|
|
||||||
type TUpdatedResponseResult =
|
|
||||||
| { updatedResponse: Awaited<ReturnType<typeof updateResponseWithQuotaEvaluation>> }
|
|
||||||
| TRouteResult;
|
|
||||||
|
|
||||||
export type TPutRouteParams = {
|
|
||||||
params: Promise<{
|
|
||||||
environmentId: string;
|
|
||||||
responseId: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDatabaseError = (
|
|
||||||
error: Error,
|
|
||||||
url: string,
|
|
||||||
endpoint: string,
|
|
||||||
responseId: string
|
|
||||||
): TRouteResult => {
|
|
||||||
if (error instanceof ResourceNotFoundError) {
|
|
||||||
return { response: responses.notFoundResponse("Response", responseId, true) };
|
|
||||||
}
|
|
||||||
if (error instanceof InvalidInputError) {
|
|
||||||
return { response: responses.badRequestResponse(error.message, undefined, true) };
|
|
||||||
}
|
|
||||||
if (error instanceof DatabaseError) {
|
|
||||||
logger.error({ error, url }, `Error in ${endpoint}`);
|
|
||||||
return {
|
|
||||||
response: responses.internalServerErrorResponse(error.message, true),
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
response: responses.internalServerErrorResponse("Unknown error occurred", true),
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateResponse = (
|
|
||||||
response: TResponse,
|
|
||||||
survey: TSurvey,
|
|
||||||
responseUpdateInput: TResponseUpdateInput
|
|
||||||
) => {
|
|
||||||
const mergedData = {
|
|
||||||
...response.data,
|
|
||||||
...responseUpdateInput.data,
|
|
||||||
};
|
|
||||||
|
|
||||||
const validationErrors = validateResponseData(
|
|
||||||
survey.blocks,
|
|
||||||
mergedData,
|
|
||||||
responseUpdateInput.language ?? response.language ?? "en",
|
|
||||||
survey.questions
|
|
||||||
);
|
|
||||||
|
|
||||||
if (validationErrors) {
|
|
||||||
return {
|
|
||||||
response: responses.badRequestResponse(
|
|
||||||
"Validation failed",
|
|
||||||
formatValidationErrorsForV1Api(validationErrors),
|
|
||||||
true
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExistingResponse = async (req: Request, responseId: string): Promise<TExistingResponseResult> => {
|
|
||||||
try {
|
|
||||||
const existingResponse = await getResponse(responseId);
|
|
||||||
|
|
||||||
return existingResponse
|
|
||||||
? { existingResponse }
|
|
||||||
: { response: responses.notFoundResponse("Response", responseId, true) };
|
|
||||||
} catch (error) {
|
|
||||||
return handleDatabaseError(
|
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
|
||||||
req.url,
|
|
||||||
"PUT /api/v1/client/[environmentId]/responses/[responseId]",
|
|
||||||
responseId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSurveyForResponse = async (
|
|
||||||
req: Request,
|
|
||||||
responseId: string,
|
|
||||||
surveyId: string
|
|
||||||
): Promise<TSurveyResult> => {
|
|
||||||
try {
|
|
||||||
const survey = await getSurvey(surveyId);
|
|
||||||
|
|
||||||
return survey ? { survey } : { response: responses.notFoundResponse("Survey", surveyId, true) };
|
|
||||||
} catch (error) {
|
|
||||||
return handleDatabaseError(
|
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
|
||||||
req.url,
|
|
||||||
"PUT /api/v1/client/[environmentId]/responses/[responseId]",
|
|
||||||
responseId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateUpdateRequest = (
|
|
||||||
existingResponse: TResponse,
|
|
||||||
survey: TSurvey,
|
|
||||||
responseUpdateInput: TResponseUpdateInput
|
|
||||||
): TRouteResult | undefined => {
|
|
||||||
if (existingResponse.finished) {
|
|
||||||
return {
|
|
||||||
response: responses.badRequestResponse("Response is already finished", undefined, true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validateFileUploads(responseUpdateInput.data, survey.questions)) {
|
|
||||||
return {
|
|
||||||
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
|
||||||
responseData: responseUpdateInput.data,
|
|
||||||
surveyQuestions: survey.questions as unknown as TSurveyElement[],
|
|
||||||
responseLanguage: responseUpdateInput.language,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (otherResponseInvalidQuestionId) {
|
|
||||||
return {
|
|
||||||
response: responses.badRequestResponse(
|
|
||||||
`Response exceeds character limit`,
|
|
||||||
{
|
|
||||||
questionId: otherResponseInvalidQuestionId,
|
|
||||||
},
|
|
||||||
true
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return validateResponse(existingResponse, survey, responseUpdateInput);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUpdatedResponse = async (
|
|
||||||
req: Request,
|
|
||||||
responseId: string,
|
|
||||||
responseUpdateInput: TResponseUpdateInput
|
|
||||||
): Promise<TUpdatedResponseResult> => {
|
|
||||||
try {
|
|
||||||
const updatedResponse = await updateResponseWithQuotaEvaluation(responseId, responseUpdateInput);
|
|
||||||
return { updatedResponse };
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ResourceNotFoundError) {
|
|
||||||
return {
|
|
||||||
response: responses.notFoundResponse("Response", responseId, true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (error instanceof InvalidInputError) {
|
|
||||||
return {
|
|
||||||
response: responses.badRequestResponse(error.message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (error instanceof DatabaseError) {
|
|
||||||
logger.error(
|
|
||||||
{ error, url: req.url },
|
|
||||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
response: responses.internalServerErrorResponse(error.message),
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const unexpectedError = error instanceof Error ? error : new Error(String(error));
|
|
||||||
|
|
||||||
logger.error(
|
|
||||||
{ error: unexpectedError, url: req.url },
|
|
||||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
response: responses.internalServerErrorResponse("Something went wrong"),
|
|
||||||
error: unexpectedError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const putResponseHandler = async ({
|
|
||||||
req,
|
|
||||||
props,
|
|
||||||
}: THandlerParams<TPutRouteParams>): Promise<TRouteResult> => {
|
|
||||||
const params = await props.params;
|
|
||||||
const { environmentId, responseId } = params;
|
|
||||||
|
|
||||||
if (!responseId) {
|
|
||||||
return {
|
|
||||||
response: responses.badRequestResponse("Response ID is missing", undefined, true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const validatedUpdateInput = await getValidatedResponseUpdateInput(req);
|
|
||||||
if ("response" in validatedUpdateInput) {
|
|
||||||
return validatedUpdateInput;
|
|
||||||
}
|
|
||||||
const { responseUpdateInput } = validatedUpdateInput;
|
|
||||||
|
|
||||||
const existingResponseResult = await getExistingResponse(req, responseId);
|
|
||||||
if ("response" in existingResponseResult) {
|
|
||||||
return existingResponseResult;
|
|
||||||
}
|
|
||||||
const { existingResponse } = existingResponseResult;
|
|
||||||
|
|
||||||
const surveyResult = await getSurveyForResponse(req, responseId, existingResponse.surveyId);
|
|
||||||
if ("response" in surveyResult) {
|
|
||||||
return surveyResult;
|
|
||||||
}
|
|
||||||
const { survey } = surveyResult;
|
|
||||||
|
|
||||||
if (survey.environmentId !== environmentId) {
|
|
||||||
return {
|
|
||||||
response: responses.notFoundResponse("Response", responseId, true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput);
|
|
||||||
if (validationResult) {
|
|
||||||
return validationResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedResponseResult = await getUpdatedResponse(req, responseId, responseUpdateInput);
|
|
||||||
if ("response" in updatedResponseResult) {
|
|
||||||
return updatedResponseResult;
|
|
||||||
}
|
|
||||||
const { updatedResponse } = updatedResponseResult;
|
|
||||||
|
|
||||||
const { quotaFull, ...responseData } = updatedResponse;
|
|
||||||
|
|
||||||
sendToPipeline({
|
|
||||||
event: "responseUpdated",
|
|
||||||
environmentId: survey.environmentId,
|
|
||||||
surveyId: survey.id,
|
|
||||||
response: responseData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (updatedResponse.finished) {
|
|
||||||
sendToPipeline({
|
|
||||||
event: "responseFinished",
|
|
||||||
environmentId: survey.environmentId,
|
|
||||||
surveyId: survey.id,
|
|
||||||
response: responseData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const quotaObj = createQuotaFullObject(quotaFull);
|
|
||||||
|
|
||||||
const responseDataWithQuota = {
|
|
||||||
id: responseData.id,
|
|
||||||
...quotaObj,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
response: responses.successResponse(responseDataWithQuota, true),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
-84
@@ -1,84 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
|
|
||||||
|
|
||||||
describe("getValidatedResponseUpdateInput", () => {
|
|
||||||
test("returns a bad request response for malformed JSON", async () => {
|
|
||||||
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: "{invalid-json",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await getValidatedResponseUpdateInput(request);
|
|
||||||
|
|
||||||
expect("response" in result).toBe(true);
|
|
||||||
|
|
||||||
if (!("response" in result)) {
|
|
||||||
throw new Error("Expected a response result");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(400);
|
|
||||||
await expect(result.response.json()).resolves.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
code: "bad_request",
|
|
||||||
message: "Malformed JSON in request body",
|
|
||||||
details: {
|
|
||||||
error: expect.any(String),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns parsed response update input for valid JSON", async () => {
|
|
||||||
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
finished: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await getValidatedResponseUpdateInput(request);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
responseUpdateInput: {
|
|
||||||
finished: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns a bad request response for schema-invalid JSON", async () => {
|
|
||||||
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
finished: "not-boolean",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await getValidatedResponseUpdateInput(request);
|
|
||||||
|
|
||||||
expect("response" in result).toBe(true);
|
|
||||||
|
|
||||||
if (!("response" in result)) {
|
|
||||||
throw new Error("Expected a response result");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(result.response.status).toBe(400);
|
|
||||||
await expect(result.response.json()).resolves.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
code: "bad_request",
|
|
||||||
message: "Fields are missing or incorrectly formatted",
|
|
||||||
details: expect.objectContaining({
|
|
||||||
finished: expect.any(String),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
import { TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
|
|
||||||
import {
|
|
||||||
TParseAndValidateJsonBodyResult,
|
|
||||||
parseAndValidateJsonBody,
|
|
||||||
} from "@/app/lib/api/parse-and-validate-json-body";
|
|
||||||
|
|
||||||
export type TValidatedResponseUpdateInputResult =
|
|
||||||
| { response: Response }
|
|
||||||
| { responseUpdateInput: TResponseUpdateInput };
|
|
||||||
|
|
||||||
export const getValidatedResponseUpdateInput = async (
|
|
||||||
req: Request
|
|
||||||
): Promise<TValidatedResponseUpdateInputResult> => {
|
|
||||||
const validatedInput: TParseAndValidateJsonBodyResult<TResponseUpdateInput> =
|
|
||||||
await parseAndValidateJsonBody({
|
|
||||||
request: req,
|
|
||||||
schema: ZResponseUpdateInput,
|
|
||||||
malformedJsonMessage: "Malformed JSON in request body",
|
|
||||||
});
|
|
||||||
|
|
||||||
if ("response" in validatedInput) {
|
|
||||||
return {
|
|
||||||
response: validatedInput.response,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { responseUpdateInput: validatedInput.data };
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,235 @@
|
|||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||||
|
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { putResponseHandler } from "./lib/put-response-handler";
|
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
|
import { getResponse } from "@/lib/response/service";
|
||||||
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
|
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||||
|
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||||
|
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||||
|
import { validateFileUploads } from "@/modules/storage/utils";
|
||||||
|
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||||
|
|
||||||
export const OPTIONS = async (): Promise<Response> => {
|
export const OPTIONS = async (): Promise<Response> => {
|
||||||
return responses.successResponse({}, true);
|
return responses.successResponse({}, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDatabaseError = (error: Error, url: string, endpoint: string, responseId: string): Response => {
|
||||||
|
if (error instanceof ResourceNotFoundError) {
|
||||||
|
return responses.notFoundResponse("Response", responseId, true);
|
||||||
|
}
|
||||||
|
if (error instanceof InvalidInputError) {
|
||||||
|
return responses.badRequestResponse(error.message, undefined, true);
|
||||||
|
}
|
||||||
|
if (error instanceof DatabaseError) {
|
||||||
|
logger.error({ error, url }, `Error in ${endpoint}`);
|
||||||
|
return responses.internalServerErrorResponse(error.message, true);
|
||||||
|
}
|
||||||
|
return responses.internalServerErrorResponse("Unknown error occurred", true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateResponse = (
|
||||||
|
response: TResponse,
|
||||||
|
survey: TSurvey,
|
||||||
|
responseUpdateInput: TResponseUpdateInput
|
||||||
|
) => {
|
||||||
|
// Validate response data against validation rules
|
||||||
|
const mergedData = {
|
||||||
|
...response.data,
|
||||||
|
...responseUpdateInput.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationErrors = validateResponseData(
|
||||||
|
survey.blocks,
|
||||||
|
mergedData,
|
||||||
|
responseUpdateInput.language ?? response.language ?? "en",
|
||||||
|
survey.questions
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validationErrors) {
|
||||||
|
return {
|
||||||
|
response: responses.badRequestResponse(
|
||||||
|
"Validation failed",
|
||||||
|
formatValidationErrorsForV1Api(validationErrors),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const PUT = withV1ApiWrapper({
|
export const PUT = withV1ApiWrapper({
|
||||||
handler: putResponseHandler,
|
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ responseId: string }> }>) => {
|
||||||
|
const params = await props.params;
|
||||||
|
const { responseId } = params;
|
||||||
|
|
||||||
|
if (!responseId) {
|
||||||
|
return {
|
||||||
|
response: responses.badRequestResponse("Response ID is missing", undefined, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseUpdate = await req.json();
|
||||||
|
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||||
|
|
||||||
|
if (!inputValidation.success) {
|
||||||
|
return {
|
||||||
|
response: responses.badRequestResponse(
|
||||||
|
"Fields are missing or incorrectly formatted",
|
||||||
|
transformErrorToDetails(inputValidation.error),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await getResponse(responseId);
|
||||||
|
} catch (error) {
|
||||||
|
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||||
|
return {
|
||||||
|
response: handleDatabaseError(
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
req.url,
|
||||||
|
endpoint,
|
||||||
|
responseId
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
return {
|
||||||
|
response: responses.notFoundResponse("Response", responseId, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.finished) {
|
||||||
|
return {
|
||||||
|
response: responses.badRequestResponse("Response is already finished", undefined, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// get survey to get environmentId
|
||||||
|
let survey;
|
||||||
|
try {
|
||||||
|
survey = await getSurvey(response.surveyId);
|
||||||
|
} catch (error) {
|
||||||
|
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||||
|
return {
|
||||||
|
response: handleDatabaseError(
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
req.url,
|
||||||
|
endpoint,
|
||||||
|
responseId
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!survey) {
|
||||||
|
return {
|
||||||
|
response: responses.notFoundResponse("Survey", response.surveyId, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
|
||||||
|
return {
|
||||||
|
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate response data for "other" options exceeding character limit
|
||||||
|
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||||
|
responseData: inputValidation.data.data,
|
||||||
|
surveyQuestions: survey.questions as unknown as TSurveyElement[],
|
||||||
|
responseLanguage: inputValidation.data.language,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (otherResponseInvalidQuestionId) {
|
||||||
|
return {
|
||||||
|
response: responses.badRequestResponse(
|
||||||
|
`Response exceeds character limit`,
|
||||||
|
{
|
||||||
|
questionId: otherResponseInvalidQuestionId,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationResult = validateResponse(response, survey, inputValidation.data);
|
||||||
|
if (validationResult) {
|
||||||
|
return validationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update response with quota evaluation
|
||||||
|
let updatedResponse;
|
||||||
|
try {
|
||||||
|
updatedResponse = await updateResponseWithQuotaEvaluation(responseId, inputValidation.data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ResourceNotFoundError) {
|
||||||
|
return {
|
||||||
|
response: responses.notFoundResponse("Response", responseId, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (error instanceof InvalidInputError) {
|
||||||
|
return {
|
||||||
|
response: responses.badRequestResponse(error.message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (error instanceof DatabaseError) {
|
||||||
|
logger.error(
|
||||||
|
{ error, url: req.url },
|
||||||
|
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
response: responses.internalServerErrorResponse(error.message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
{ error, url: req.url },
|
||||||
|
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
response: responses.internalServerErrorResponse("Something went wrong"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { quotaFull, ...responseData } = updatedResponse;
|
||||||
|
|
||||||
|
// send response update to pipeline
|
||||||
|
// don't await to not block the response
|
||||||
|
sendToPipeline({
|
||||||
|
event: "responseUpdated",
|
||||||
|
environmentId: survey.environmentId,
|
||||||
|
surveyId: survey.id,
|
||||||
|
response: responseData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updatedResponse.finished) {
|
||||||
|
// send response to pipeline
|
||||||
|
// don't await to not block the response
|
||||||
|
sendToPipeline({
|
||||||
|
event: "responseFinished",
|
||||||
|
environmentId: survey.environmentId,
|
||||||
|
surveyId: survey.id,
|
||||||
|
response: responseData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotaObj = createQuotaFullObject(quotaFull);
|
||||||
|
|
||||||
|
const responseDataWithQuota = {
|
||||||
|
id: responseData.id,
|
||||||
|
...quotaObj,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: responses.successResponse(responseDataWithQuota, true),
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { ZUploadPrivateFileRequest } from "@formbricks/types/storage";
|
import { TUploadPrivateFileRequest, ZUploadPrivateFileRequest } from "@formbricks/types/storage";
|
||||||
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
|
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
@@ -30,27 +30,33 @@ export const POST = withV1ApiWrapper({
|
|||||||
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
|
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { environmentId } = params;
|
const { environmentId } = params;
|
||||||
const parsedInputResult = await parseAndValidateJsonBody({
|
let jsonInput: TUploadPrivateFileRequest;
|
||||||
request: req,
|
|
||||||
schema: ZUploadPrivateFileRequest,
|
try {
|
||||||
buildInput: (jsonInput) => ({
|
jsonInput = await req.json();
|
||||||
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
|
} catch (error) {
|
||||||
environmentId,
|
logger.error({ error, url: req.url }, "Error parsing JSON input");
|
||||||
}),
|
return {
|
||||||
|
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedInputResult = ZUploadPrivateFileRequest.safeParse({
|
||||||
|
...jsonInput,
|
||||||
|
environmentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if ("response" in parsedInputResult) {
|
if (!parsedInputResult.success) {
|
||||||
if (parsedInputResult.issue === "invalid_json") {
|
const errorDetails = transformErrorToDetails(parsedInputResult.error);
|
||||||
logger.error({ error: parsedInputResult.details, url: req.url }, "Error parsing JSON input");
|
|
||||||
} else {
|
logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted");
|
||||||
logger.error(
|
|
||||||
{ error: parsedInputResult.details, url: req.url },
|
|
||||||
"Fields are missing or incorrectly formatted"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: parsedInputResult.response,
|
response: responses.badRequestResponse(
|
||||||
|
"Fields are missing or incorrectly formatted",
|
||||||
|
errorDetails,
|
||||||
|
true
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,14 +105,9 @@ export const POST = withV1ApiWrapper({
|
|||||||
if (!signedUrlResponse.ok) {
|
if (!signedUrlResponse.ok) {
|
||||||
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
|
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
|
||||||
const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName });
|
const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName });
|
||||||
return errorResponse.status >= 500
|
return {
|
||||||
? {
|
response: errorResponse,
|
||||||
response: errorResponse,
|
};
|
||||||
error: signedUrlResponse.error,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
response: errorResponse,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { fetchAirtableAuthToken } from "@/lib/airtable/service";
|
|||||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
|
||||||
|
|
||||||
const getEmail = async (token: string) => {
|
const getEmail = async (token: string) => {
|
||||||
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
|
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
|
||||||
@@ -88,17 +86,6 @@ export const GET = withV1ApiWrapper({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
||||||
|
|
||||||
try {
|
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
|
||||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
|
||||||
integration_type: "airtable",
|
|
||||||
organization_id: organizationId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(
|
response: Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
@@ -12,8 +11,6 @@ import {
|
|||||||
import { symmetricEncrypt } from "@/lib/crypto";
|
import { symmetricEncrypt } from "@/lib/crypto";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
|
||||||
|
|
||||||
export const GET = withV1ApiWrapper({
|
export const GET = withV1ApiWrapper({
|
||||||
handler: async ({ req, authentication }) => {
|
handler: async ({ req, authentication }) => {
|
||||||
@@ -99,16 +96,6 @@ export const GET = withV1ApiWrapper({
|
|||||||
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
|
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
try {
|
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
|
||||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
|
||||||
integration_type: "notion",
|
|
||||||
organization_id: organizationId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(
|
response: Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import {
|
import {
|
||||||
TIntegrationSlackConfig,
|
TIntegrationSlackConfig,
|
||||||
TIntegrationSlackConfigData,
|
TIntegrationSlackConfigData,
|
||||||
@@ -9,8 +8,6 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
|||||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
|
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
|
||||||
|
|
||||||
export const GET = withV1ApiWrapper({
|
export const GET = withV1ApiWrapper({
|
||||||
handler: async ({ req, authentication }) => {
|
handler: async ({ req, authentication }) => {
|
||||||
@@ -107,16 +104,6 @@ export const GET = withV1ApiWrapper({
|
|||||||
const result = await createOrUpdateIntegration(environmentId, integration);
|
const result = await createOrUpdateIntegration(environmentId, integration);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
try {
|
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
|
||||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
|
||||||
integration_type: "slack",
|
|
||||||
organization_id: organizationId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: Response.redirect(
|
response: Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export const PUT = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization, result.survey);
|
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization);
|
||||||
if (featureCheckResult) {
|
if (featureCheckResult) {
|
||||||
return {
|
return {
|
||||||
response: featureCheckResult,
|
response: featureCheckResult,
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import {
|
import {
|
||||||
TSurvey,
|
|
||||||
TSurveyCreateInputWithEnvironmentId,
|
TSurveyCreateInputWithEnvironmentId,
|
||||||
TSurveyQuestionTypeEnum,
|
TSurveyQuestionTypeEnum,
|
||||||
} from "@formbricks/types/surveys/types";
|
} from "@formbricks/types/surveys/types";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
|
||||||
import { checkFeaturePermissions } from "./utils";
|
import { checkFeaturePermissions } from "./utils";
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
@@ -26,14 +24,6 @@ vi.mock("@/modules/survey/follow-ups/lib/utils", () => ({
|
|||||||
getSurveyFollowUpsPermission: vi.fn(),
|
getSurveyFollowUpsPermission: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/survey/lib/permission", () => ({
|
|
||||||
getExternalUrlsPermission: vi.fn().mockResolvedValue(true),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/survey/utils", () => ({
|
|
||||||
getElementsFromBlocks: vi.fn((blocks: any[]) => blocks.flatMap((block: any) => block.elements)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockOrganization: TOrganization = {
|
const mockOrganization: TOrganization = {
|
||||||
id: "test-org",
|
id: "test-org",
|
||||||
name: "Test Organization",
|
name: "Test Organization",
|
||||||
@@ -49,8 +39,7 @@ const mockOrganization: TOrganization = {
|
|||||||
},
|
},
|
||||||
usageCycleAnchor: new Date(),
|
usageCycleAnchor: new Date(),
|
||||||
},
|
},
|
||||||
isAISmartToolsEnabled: false,
|
isAIEnabled: false,
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {
|
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {
|
||||||
@@ -109,13 +98,6 @@ const baseSurveyData: TSurveyCreateInputWithEnvironmentId = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("checkFeaturePermissions", () => {
|
describe("checkFeaturePermissions", () => {
|
||||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return null if no restricted features are used", async () => {
|
test("should return null if no restricted features are used", async () => {
|
||||||
const surveyData = { ...baseSurveyData };
|
const surveyData = { ...baseSurveyData };
|
||||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
||||||
@@ -215,315 +197,4 @@ describe("checkFeaturePermissions", () => {
|
|||||||
);
|
);
|
||||||
expect(responses.forbiddenResponse).toHaveBeenCalledTimes(1); // Ensure it stops at the first failure
|
expect(responses.forbiddenResponse).toHaveBeenCalledTimes(1); // Ensure it stops at the first failure
|
||||||
});
|
});
|
||||||
|
|
||||||
// External URLs - ending card button link tests
|
|
||||||
test("should return forbiddenResponse when adding new ending with buttonLink without permission", async () => {
|
|
||||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
|
||||||
const surveyData = {
|
|
||||||
...baseSurveyData,
|
|
||||||
endings: [
|
|
||||||
{
|
|
||||||
id: "ending1",
|
|
||||||
type: "endScreen" as const,
|
|
||||||
headline: { default: "Thanks" },
|
|
||||||
subheader: { default: "" },
|
|
||||||
buttonLink: "https://example.com",
|
|
||||||
buttonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
|
||||||
expect(result).toBeInstanceOf(Response);
|
|
||||||
expect(result?.status).toBe(403);
|
|
||||||
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
|
|
||||||
"External URLs are not enabled for this organization. Upgrade to use external button links."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return forbiddenResponse when changing ending buttonLink without permission", async () => {
|
|
||||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
|
||||||
const surveyData = {
|
|
||||||
...baseSurveyData,
|
|
||||||
endings: [
|
|
||||||
{
|
|
||||||
id: "ending1",
|
|
||||||
type: "endScreen" as const,
|
|
||||||
headline: { default: "Thanks" },
|
|
||||||
subheader: { default: "" },
|
|
||||||
buttonLink: "https://new-url.com",
|
|
||||||
buttonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const oldSurvey = {
|
|
||||||
endings: [
|
|
||||||
{
|
|
||||||
id: "ending1",
|
|
||||||
type: "endScreen" as const,
|
|
||||||
headline: { default: "Thanks" },
|
|
||||||
subheader: { default: "" },
|
|
||||||
buttonLink: "https://old-url.com",
|
|
||||||
buttonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as unknown as TSurvey;
|
|
||||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
|
||||||
expect(result).toBeInstanceOf(Response);
|
|
||||||
expect(result?.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should allow keeping existing ending buttonLink without permission (grandfathering)", async () => {
|
|
||||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
|
||||||
const surveyData = {
|
|
||||||
...baseSurveyData,
|
|
||||||
endings: [
|
|
||||||
{
|
|
||||||
id: "ending1",
|
|
||||||
type: "endScreen" as const,
|
|
||||||
headline: { default: "Thanks" },
|
|
||||||
subheader: { default: "" },
|
|
||||||
buttonLink: "https://existing-url.com",
|
|
||||||
buttonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const oldSurvey = {
|
|
||||||
endings: [
|
|
||||||
{
|
|
||||||
id: "ending1",
|
|
||||||
type: "endScreen" as const,
|
|
||||||
headline: { default: "Thanks" },
|
|
||||||
subheader: { default: "" },
|
|
||||||
buttonLink: "https://existing-url.com",
|
|
||||||
buttonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as unknown as TSurvey;
|
|
||||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should allow ending buttonLink when permission is granted", async () => {
|
|
||||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
|
||||||
const surveyData = {
|
|
||||||
...baseSurveyData,
|
|
||||||
endings: [
|
|
||||||
{
|
|
||||||
id: "ending1",
|
|
||||||
type: "endScreen" as const,
|
|
||||||
headline: { default: "Thanks" },
|
|
||||||
subheader: { default: "" },
|
|
||||||
buttonLink: "https://example.com",
|
|
||||||
buttonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
// External URLs - CTA external button tests
|
|
||||||
test("should return forbiddenResponse when adding CTA with external button without permission", async () => {
|
|
||||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
|
||||||
const surveyData = {
|
|
||||||
...baseSurveyData,
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
id: "block1",
|
|
||||||
name: "Block 1",
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: "cta1",
|
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
|
||||||
headline: { default: "CTA" },
|
|
||||||
required: false,
|
|
||||||
buttonExternal: true,
|
|
||||||
buttonUrl: "https://example.com",
|
|
||||||
ctaButtonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
buttonLabel: { default: "Next" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
|
||||||
expect(result).toBeInstanceOf(Response);
|
|
||||||
expect(result?.status).toBe(403);
|
|
||||||
expect(responses.forbiddenResponse).toHaveBeenCalledWith(
|
|
||||||
"External URLs are not enabled for this organization. Upgrade to use external CTA buttons."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return forbiddenResponse when changing CTA external button URL without permission", async () => {
|
|
||||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
|
||||||
const surveyData = {
|
|
||||||
...baseSurveyData,
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
id: "block1",
|
|
||||||
name: "Block 1",
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: "cta1",
|
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
|
||||||
headline: { default: "CTA" },
|
|
||||||
required: false,
|
|
||||||
buttonExternal: true,
|
|
||||||
buttonUrl: "https://new-url.com",
|
|
||||||
ctaButtonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
buttonLabel: { default: "Next" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const oldSurvey = {
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
id: "block1",
|
|
||||||
name: "Block 1",
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: "cta1",
|
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
|
||||||
headline: { default: "CTA" },
|
|
||||||
required: false,
|
|
||||||
buttonExternal: true,
|
|
||||||
buttonUrl: "https://old-url.com",
|
|
||||||
ctaButtonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
buttonLabel: { default: "Next" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
endings: [],
|
|
||||||
} as unknown as TSurvey;
|
|
||||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
|
||||||
expect(result).toBeInstanceOf(Response);
|
|
||||||
expect(result?.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should allow keeping existing CTA external button without permission (grandfathering)", async () => {
|
|
||||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
|
||||||
const surveyData = {
|
|
||||||
...baseSurveyData,
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
id: "block1",
|
|
||||||
name: "Block 1",
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: "cta1",
|
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
|
||||||
headline: { default: "CTA" },
|
|
||||||
required: false,
|
|
||||||
buttonExternal: true,
|
|
||||||
buttonUrl: "https://existing-url.com",
|
|
||||||
ctaButtonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
buttonLabel: { default: "Next" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const oldSurvey = {
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
id: "block1",
|
|
||||||
name: "Block 1",
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: "cta1",
|
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
|
||||||
headline: { default: "CTA" },
|
|
||||||
required: false,
|
|
||||||
buttonExternal: true,
|
|
||||||
buttonUrl: "https://existing-url.com",
|
|
||||||
ctaButtonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
buttonLabel: { default: "Next" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
endings: [],
|
|
||||||
} as unknown as TSurvey;
|
|
||||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should allow CTA external button when permission is granted", async () => {
|
|
||||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
|
||||||
const surveyData = {
|
|
||||||
...baseSurveyData,
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
id: "block1",
|
|
||||||
name: "Block 1",
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: "cta1",
|
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
|
||||||
headline: { default: "CTA" },
|
|
||||||
required: false,
|
|
||||||
buttonExternal: true,
|
|
||||||
buttonUrl: "https://example.com",
|
|
||||||
ctaButtonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
buttonLabel: { default: "Next" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const result = await checkFeaturePermissions(surveyData, mockOrganization);
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return forbiddenResponse when switching CTA from internal to external without permission", async () => {
|
|
||||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(false);
|
|
||||||
const surveyData = {
|
|
||||||
...baseSurveyData,
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
id: "block1",
|
|
||||||
name: "Block 1",
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: "cta1",
|
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
|
||||||
headline: { default: "CTA" },
|
|
||||||
required: false,
|
|
||||||
buttonExternal: true,
|
|
||||||
buttonUrl: "https://example.com",
|
|
||||||
ctaButtonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
buttonLabel: { default: "Next" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const oldSurvey = {
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
id: "block1",
|
|
||||||
name: "Block 1",
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: "cta1",
|
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
|
||||||
headline: { default: "CTA" },
|
|
||||||
required: false,
|
|
||||||
buttonExternal: false,
|
|
||||||
buttonUrl: "",
|
|
||||||
ctaButtonLabel: { default: "Click" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
buttonLabel: { default: "Next" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
endings: [],
|
|
||||||
} as unknown as TSurvey;
|
|
||||||
const result = await checkFeaturePermissions(surveyData, mockOrganization, oldSurvey);
|
|
||||||
expect(result).toBeInstanceOf(Response);
|
|
||||||
expect(result?.status).toBe(403);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TSurvey, TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
import { TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
|
||||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
|
||||||
|
|
||||||
export const checkFeaturePermissions = async (
|
export const checkFeaturePermissions = async (
|
||||||
surveyData: TSurveyCreateInputWithEnvironmentId,
|
surveyData: TSurveyCreateInputWithEnvironmentId,
|
||||||
organization: TOrganization,
|
organization: TOrganization
|
||||||
oldSurvey?: TSurvey
|
|
||||||
): Promise<Response | null> => {
|
): Promise<Response | null> => {
|
||||||
if (surveyData.recaptcha?.enabled) {
|
if (surveyData.recaptcha?.enabled) {
|
||||||
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.id);
|
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.id);
|
||||||
@@ -25,46 +22,5 @@ export const checkFeaturePermissions = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExternalUrlsAllowed = await getExternalUrlsPermission(organization.id);
|
|
||||||
if (!isExternalUrlsAllowed) {
|
|
||||||
// Check ending cards for new/changed button links
|
|
||||||
if (surveyData.endings) {
|
|
||||||
for (const newEnding of surveyData.endings) {
|
|
||||||
const oldEnding = oldSurvey?.endings.find((e) => e.id === newEnding.id);
|
|
||||||
|
|
||||||
if (newEnding.type === "endScreen" && newEnding.buttonLink) {
|
|
||||||
if (!oldEnding || oldEnding.type !== "endScreen" || oldEnding.buttonLink !== newEnding.buttonLink) {
|
|
||||||
return responses.forbiddenResponse(
|
|
||||||
"External URLs are not enabled for this organization. Upgrade to use external button links."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check CTA elements for new/changed external button URLs
|
|
||||||
if (surveyData.blocks) {
|
|
||||||
const newElements = getElementsFromBlocks(surveyData.blocks);
|
|
||||||
const oldElements = oldSurvey?.blocks ? getElementsFromBlocks(oldSurvey.blocks) : [];
|
|
||||||
|
|
||||||
for (const newElement of newElements) {
|
|
||||||
const oldElement = oldElements.find((e) => e.id === newElement.id);
|
|
||||||
|
|
||||||
if (newElement.type === "cta" && newElement.buttonExternal) {
|
|
||||||
if (
|
|
||||||
!oldElement ||
|
|
||||||
oldElement.type !== "cta" ||
|
|
||||||
!oldElement.buttonExternal ||
|
|
||||||
oldElement.buttonUrl !== newElement.buttonUrl
|
|
||||||
) {
|
|
||||||
return responses.forbiddenResponse(
|
|
||||||
"External URLs are not enabled for this organization. Upgrade to use external CTA buttons."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
createDisplay: vi.fn(),
|
|
||||||
getIsContactsEnabled: vi.fn(),
|
|
||||||
getOrganizationIdFromEnvironmentId: vi.fn(),
|
|
||||||
reportApiError: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./lib/display", () => ({
|
|
||||||
createDisplay: mocks.createDisplay,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
|
||||||
getIsContactsEnabled: mocks.getIsContactsEnabled,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/helper", () => ({
|
|
||||||
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/lib/api/api-error-reporter", () => ({
|
|
||||||
reportApiError: mocks.reportApiError,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const environmentId = "cld1234567890abcdef123456";
|
|
||||||
const surveyId = "clg123456789012345678901234";
|
|
||||||
|
|
||||||
describe("api/v2 client displays route", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
|
|
||||||
mocks.getIsContactsEnabled.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns a v2 bad request response for malformed JSON without reporting an internal error", async () => {
|
|
||||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: "{",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { POST } = await import("./route");
|
|
||||||
const response = await POST(request, {
|
|
||||||
params: Promise.resolve({ environmentId }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(await response.json()).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
code: "bad_request",
|
|
||||||
message: "Invalid JSON in request body",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(mocks.createDisplay).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.reportApiError).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reports unexpected createDisplay failures while keeping the response payload unchanged", async () => {
|
|
||||||
const underlyingError = new Error("display persistence failed");
|
|
||||||
mocks.createDisplay.mockRejectedValue(underlyingError);
|
|
||||||
|
|
||||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
surveyId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { POST } = await import("./route");
|
|
||||||
const response = await POST(request, {
|
|
||||||
params: Promise.resolve({ environmentId }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(await response.json()).toEqual({
|
|
||||||
code: "internal_server_error",
|
|
||||||
message: "Something went wrong. Please try again.",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
expect(mocks.reportApiError).toHaveBeenCalledWith({
|
|
||||||
request,
|
|
||||||
status: 500,
|
|
||||||
error: underlyingError,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reports unexpected contact-license lookup failures with the same generic public response", async () => {
|
|
||||||
const underlyingError = new Error("license lookup failed");
|
|
||||||
mocks.getOrganizationIdFromEnvironmentId.mockRejectedValue(underlyingError);
|
|
||||||
|
|
||||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
surveyId,
|
|
||||||
contactId: "clh123456789012345678901234",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { POST } = await import("./route");
|
|
||||||
const response = await POST(request, {
|
|
||||||
params: Promise.resolve({ environmentId }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(await response.json()).toEqual({
|
|
||||||
code: "internal_server_error",
|
|
||||||
message: "Something went wrong. Please try again.",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
expect(mocks.reportApiError).toHaveBeenCalledWith({
|
|
||||||
request,
|
|
||||||
status: 500,
|
|
||||||
error: underlyingError,
|
|
||||||
});
|
|
||||||
expect(mocks.createDisplay).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
|
import { logger } from "@formbricks/logger";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import {
|
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||||
TDisplayCreateInputV2,
|
|
||||||
ZDisplayCreateInputV2,
|
|
||||||
} from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
|
||||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
|
||||||
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createDisplay } from "./lib/display";
|
import { createDisplay } from "./lib/display";
|
||||||
@@ -16,29 +13,6 @@ interface Context {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TValidatedDisplayInputResult = { displayInputData: TDisplayCreateInputV2 } | { response: Response };
|
|
||||||
|
|
||||||
const parseAndValidateDisplayInput = async (
|
|
||||||
request: Request,
|
|
||||||
environmentId: string
|
|
||||||
): Promise<TValidatedDisplayInputResult> => {
|
|
||||||
const inputValidation = await parseAndValidateJsonBody({
|
|
||||||
request,
|
|
||||||
schema: ZDisplayCreateInputV2,
|
|
||||||
buildInput: (jsonInput) => ({
|
|
||||||
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
|
|
||||||
environmentId,
|
|
||||||
}),
|
|
||||||
malformedJsonMessage: "Invalid JSON in request body",
|
|
||||||
});
|
|
||||||
|
|
||||||
if ("response" in inputValidation) {
|
|
||||||
return inputValidation;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { displayInputData: inputValidation.data };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OPTIONS = async (): Promise<Response> => {
|
export const OPTIONS = async (): Promise<Response> => {
|
||||||
return responses.successResponse(
|
return responses.successResponse(
|
||||||
{},
|
{},
|
||||||
@@ -51,40 +25,38 @@ export const OPTIONS = async (): Promise<Response> => {
|
|||||||
|
|
||||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||||
const params = await context.params;
|
const params = await context.params;
|
||||||
const validatedInput = await parseAndValidateDisplayInput(request, params.environmentId);
|
const jsonInput = await request.json();
|
||||||
|
const inputValidation = ZDisplayCreateInputV2.safeParse({
|
||||||
|
...jsonInput,
|
||||||
|
environmentId: params.environmentId,
|
||||||
|
});
|
||||||
|
|
||||||
if ("response" in validatedInput) {
|
if (!inputValidation.success) {
|
||||||
return validatedInput.response;
|
return responses.badRequestResponse(
|
||||||
|
"Fields are missing or incorrectly formatted",
|
||||||
|
transformErrorToDetails(inputValidation.error),
|
||||||
|
true
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { displayInputData } = validatedInput;
|
if (inputValidation.data.contactId) {
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
|
||||||
|
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||||
|
if (!isContactsEnabled) {
|
||||||
|
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (displayInputData.contactId) {
|
const response = await createDisplay(inputValidation.data);
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
|
|
||||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
|
||||||
if (!isContactsEnabled) {
|
|
||||||
return responses.forbiddenResponse(
|
|
||||||
"User identification is only available for enterprise users.",
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await createDisplay(displayInputData);
|
|
||||||
|
|
||||||
return responses.successResponse(response, true);
|
return responses.successResponse(response, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ResourceNotFoundError) {
|
if (error instanceof ResourceNotFoundError) {
|
||||||
return responses.notFoundResponse("Survey", displayInputData.surveyId, true);
|
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
|
||||||
|
} else {
|
||||||
|
logger.error({ error, url: request.url }, "Error creating display");
|
||||||
|
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
|
|
||||||
reportApiError({
|
|
||||||
request,
|
|
||||||
status: response.status,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { type NextRequest } from "next/server";
|
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
applyIPRateLimit: vi.fn(),
|
|
||||||
getEnvironmentState: vi.fn(),
|
|
||||||
contextualLoggerError: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/api/v1/client/[environmentId]/environment/lib/environmentState", () => ({
|
|
||||||
getEnvironmentState: mocks.getEnvironmentState,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
|
||||||
applyIPRateLimit: mocks.applyIPRateLimit,
|
|
||||||
applyRateLimit: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
|
||||||
rateLimitConfigs: {
|
|
||||||
api: {
|
|
||||||
client: { windowMs: 60000, max: 100 },
|
|
||||||
v1: { windowMs: 60000, max: 1000 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@sentry/nextjs", () => ({
|
|
||||||
captureException: vi.fn(),
|
|
||||||
withScope: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@formbricks/logger", () => ({
|
|
||||||
logger: {
|
|
||||||
withContext: vi.fn(() => ({
|
|
||||||
error: mocks.contextualLoggerError,
|
|
||||||
warn: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
})),
|
|
||||||
error: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
AUDIT_LOG_ENABLED: false,
|
|
||||||
IS_PRODUCTION: true,
|
|
||||||
SENTRY_DSN: "test-dsn",
|
|
||||||
ENCRYPTION_KEY: "test-key",
|
|
||||||
REDIS_URL: "redis://localhost:6379",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMockRequest = (url: string, headers = new Map<string, string>()): NextRequest => {
|
|
||||||
const parsedUrl = new URL(url);
|
|
||||||
|
|
||||||
return {
|
|
||||||
method: "GET",
|
|
||||||
url,
|
|
||||||
headers: {
|
|
||||||
get: (key: string) => headers.get(key),
|
|
||||||
},
|
|
||||||
nextUrl: {
|
|
||||||
pathname: parsedUrl.pathname,
|
|
||||||
},
|
|
||||||
} as unknown as NextRequest;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("api/v2 client environment route", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mocks.applyIPRateLimit.mockResolvedValue(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reports v1-backed failures as v2 and keeps the response payload unchanged", async () => {
|
|
||||||
const underlyingError = new Error("Environment load failed");
|
|
||||||
mocks.getEnvironmentState.mockRejectedValue(underlyingError);
|
|
||||||
|
|
||||||
const request = createMockRequest(
|
|
||||||
"https://api.test/api/v2/client/ck12345678901234567890123/environment",
|
|
||||||
new Map([["x-request-id", "req-v2-env"]])
|
|
||||||
);
|
|
||||||
|
|
||||||
const { GET } = await import("./route");
|
|
||||||
const response = await GET(request, {
|
|
||||||
params: Promise.resolve({
|
|
||||||
environmentId: "ck12345678901234567890123",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(await response.json()).toEqual({
|
|
||||||
code: "internal_server_error",
|
|
||||||
message: "An error occurred while processing your request.",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
|
||||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
|
||||||
underlyingError,
|
|
||||||
expect.objectContaining({
|
|
||||||
tags: expect.objectContaining({
|
|
||||||
apiVersion: "v2",
|
|
||||||
correlationId: "req-v2-env",
|
|
||||||
method: "GET",
|
|
||||||
path: "/api/v2/client/ck12345678901234567890123/environment",
|
|
||||||
}),
|
|
||||||
extra: expect.objectContaining({
|
|
||||||
error: expect.objectContaining({
|
|
||||||
name: "Error",
|
|
||||||
message: "Environment load failed",
|
|
||||||
}),
|
|
||||||
originalError: expect.objectContaining({
|
|
||||||
name: "Error",
|
|
||||||
message: "Environment load failed",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
contexts: expect.objectContaining({
|
|
||||||
apiRequest: expect.objectContaining({
|
|
||||||
apiVersion: "v2",
|
|
||||||
correlationId: "req-v2-env",
|
|
||||||
method: "GET",
|
|
||||||
path: "/api/v2/client/ck12345678901234567890123/environment",
|
|
||||||
status: 500,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
checkSurveyValidity: vi.fn(),
|
|
||||||
createResponseWithQuotaEvaluation: vi.fn(),
|
|
||||||
getClientIpFromHeaders: vi.fn(),
|
|
||||||
getIsContactsEnabled: vi.fn(),
|
|
||||||
getOrganizationIdFromEnvironmentId: vi.fn(),
|
|
||||||
getSurvey: vi.fn(),
|
|
||||||
reportApiError: vi.fn(),
|
|
||||||
sendToPipeline: vi.fn(),
|
|
||||||
validateResponseData: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/utils", () => ({
|
|
||||||
checkSurveyValidity: mocks.checkSurveyValidity,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./lib/response", () => ({
|
|
||||||
createResponseWithQuotaEvaluation: mocks.createResponseWithQuotaEvaluation,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/lib/api/api-error-reporter", () => ({
|
|
||||||
reportApiError: mocks.reportApiError,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/lib/pipelines", () => ({
|
|
||||||
sendToPipeline: mocks.sendToPipeline,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/survey/service", () => ({
|
|
||||||
getSurvey: mocks.getSurvey,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/client-ip", () => ({
|
|
||||||
getClientIpFromHeaders: mocks.getClientIpFromHeaders,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/helper", () => ({
|
|
||||||
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/api/lib/validation", () => ({
|
|
||||||
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
|
|
||||||
validateResponseData: mocks.validateResponseData,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
|
||||||
getIsContactsEnabled: mocks.getIsContactsEnabled,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const environmentId = "cld1234567890abcdef123456";
|
|
||||||
const surveyId = "clg123456789012345678901234";
|
|
||||||
|
|
||||||
describe("api/v2 client responses route", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mocks.checkSurveyValidity.mockResolvedValue(null);
|
|
||||||
mocks.getSurvey.mockResolvedValue({
|
|
||||||
id: surveyId,
|
|
||||||
environmentId,
|
|
||||||
blocks: [],
|
|
||||||
questions: [],
|
|
||||||
isCaptureIpEnabled: false,
|
|
||||||
});
|
|
||||||
mocks.validateResponseData.mockReturnValue(null);
|
|
||||||
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
|
|
||||||
mocks.getIsContactsEnabled.mockResolvedValue(true);
|
|
||||||
mocks.getClientIpFromHeaders.mockResolvedValue("127.0.0.1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reports unexpected response creation failures while keeping the public payload generic", async () => {
|
|
||||||
const underlyingError = new Error("response persistence failed");
|
|
||||||
mocks.createResponseWithQuotaEvaluation.mockRejectedValue(underlyingError);
|
|
||||||
|
|
||||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-request-id": "req-v2-response",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
surveyId,
|
|
||||||
finished: false,
|
|
||||||
data: {},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { POST } = await import("./route");
|
|
||||||
const response = await POST(request, {
|
|
||||||
params: Promise.resolve({ environmentId }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(await response.json()).toEqual({
|
|
||||||
code: "internal_server_error",
|
|
||||||
message: "Something went wrong. Please try again.",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
expect(mocks.reportApiError).toHaveBeenCalledWith({
|
|
||||||
request,
|
|
||||||
status: 500,
|
|
||||||
error: underlyingError,
|
|
||||||
});
|
|
||||||
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reports unexpected pre-persistence failures with the same generic public response", async () => {
|
|
||||||
const underlyingError = new Error("survey lookup failed");
|
|
||||||
mocks.getSurvey.mockRejectedValue(underlyingError);
|
|
||||||
|
|
||||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-request-id": "req-v2-response-pre-check",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
surveyId,
|
|
||||||
finished: false,
|
|
||||||
data: {},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { POST } = await import("./route");
|
|
||||||
const response = await POST(request, {
|
|
||||||
params: Promise.resolve({ environmentId }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
expect(await response.json()).toEqual({
|
|
||||||
code: "internal_server_error",
|
|
||||||
message: "Something went wrong. Please try again.",
|
|
||||||
details: {},
|
|
||||||
});
|
|
||||||
expect(mocks.reportApiError).toHaveBeenCalledWith({
|
|
||||||
request,
|
|
||||||
status: 500,
|
|
||||||
error: underlyingError,
|
|
||||||
});
|
|
||||||
expect(mocks.createResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
import { UAParser } from "ua-parser-js";
|
import { UAParser } from "ua-parser-js";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||||
import { InvalidInputError } from "@formbricks/types/errors";
|
import { InvalidInputError } from "@formbricks/types/errors";
|
||||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||||
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
||||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
|
||||||
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||||
@@ -25,86 +25,78 @@ interface Context {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TResponseSurvey = NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
|
export const OPTIONS = async (): Promise<Response> => {
|
||||||
|
return responses.successResponse(
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
// Cache CORS preflight responses for 1 hour (conservative approach)
|
||||||
|
// Balances performance gains with flexibility for CORS policy changes
|
||||||
|
"public, s-maxage=3600, max-age=3600"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type TValidatedResponseInputResult =
|
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||||
| {
|
const params = await context.params;
|
||||||
environmentId: string;
|
const requestHeaders = await headers();
|
||||||
responseInputData: TResponseInputV2;
|
let responseInput;
|
||||||
}
|
try {
|
||||||
| { response: Response };
|
responseInput = await request.json();
|
||||||
|
} catch (error) {
|
||||||
|
return responses.badRequestResponse(
|
||||||
|
"Invalid JSON in request body",
|
||||||
|
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const getCountry = (requestHeaders: Headers): string | undefined =>
|
const { environmentId } = params;
|
||||||
requestHeaders.get("CF-IPCountry") ||
|
|
||||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
|
||||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
|
||||||
undefined;
|
|
||||||
|
|
||||||
const getUnexpectedPublicErrorResponse = (): Response =>
|
|
||||||
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
|
|
||||||
|
|
||||||
const parseAndValidateResponseInput = async (
|
|
||||||
request: Request,
|
|
||||||
environmentId: string
|
|
||||||
): Promise<TValidatedResponseInputResult> => {
|
|
||||||
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
|
||||||
|
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
|
||||||
|
|
||||||
if (!environmentIdValidation.success) {
|
if (!environmentIdValidation.success) {
|
||||||
return {
|
return responses.badRequestResponse(
|
||||||
response: responses.badRequestResponse(
|
"Fields are missing or incorrectly formatted",
|
||||||
"Fields are missing or incorrectly formatted",
|
transformErrorToDetails(environmentIdValidation.error),
|
||||||
transformErrorToDetails(environmentIdValidation.error),
|
true
|
||||||
true
|
);
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseInputValidation = await parseAndValidateJsonBody({
|
if (!responseInputValidation.success) {
|
||||||
request,
|
return responses.badRequestResponse(
|
||||||
schema: ZResponseInputV2,
|
"Fields are missing or incorrectly formatted",
|
||||||
buildInput: (jsonInput) => ({
|
transformErrorToDetails(responseInputValidation.error),
|
||||||
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
|
true
|
||||||
environmentId,
|
);
|
||||||
}),
|
|
||||||
malformedJsonMessage: "Invalid JSON in request body",
|
|
||||||
});
|
|
||||||
|
|
||||||
if ("response" in responseInputValidation) {
|
|
||||||
return responseInputValidation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const userAgent = request.headers.get("user-agent") || undefined;
|
||||||
environmentId,
|
const agent = new UAParser(userAgent);
|
||||||
responseInputData: responseInputValidation.data,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getContactsDisabledResponse = async (
|
const country =
|
||||||
environmentId: string,
|
requestHeaders.get("CF-IPCountry") ||
|
||||||
contactId: string | null | undefined
|
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||||
): Promise<Response | null> => {
|
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||||
if (!contactId) {
|
undefined;
|
||||||
return null;
|
|
||||||
|
const responseInputData = responseInputValidation.data;
|
||||||
|
|
||||||
|
if (responseInputData.contactId) {
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||||
|
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||||
|
if (!isContactsEnabled) {
|
||||||
|
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
// get and check survey
|
||||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
const survey = await getSurvey(responseInputData.surveyId);
|
||||||
|
if (!survey) {
|
||||||
return isContactsEnabled
|
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
|
||||||
? null
|
|
||||||
: responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateResponseSubmission = async (
|
|
||||||
environmentId: string,
|
|
||||||
responseInputData: TResponseInputV2,
|
|
||||||
survey: TResponseSurvey
|
|
||||||
): Promise<Response | null> => {
|
|
||||||
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInputData);
|
|
||||||
if (surveyCheckResult) {
|
|
||||||
return surveyCheckResult;
|
|
||||||
}
|
}
|
||||||
|
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInput);
|
||||||
|
if (surveyCheckResult) return surveyCheckResult;
|
||||||
|
|
||||||
|
// Validate response data for "other" options exceeding character limit
|
||||||
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
|
||||||
responseData: responseInputData.data,
|
responseData: responseInputData.data,
|
||||||
surveyQuestions: getElementsFromBlocks(survey.blocks),
|
surveyQuestions: getElementsFromBlocks(survey.blocks),
|
||||||
@@ -121,6 +113,7 @@ const validateResponseSubmission = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate response data against validation rules
|
||||||
const validationErrors = validateResponseData(
|
const validationErrors = validateResponseData(
|
||||||
survey.blocks,
|
survey.blocks,
|
||||||
responseInputData.data,
|
responseInputData.data,
|
||||||
@@ -128,29 +121,15 @@ const validateResponseSubmission = async (
|
|||||||
survey.questions
|
survey.questions
|
||||||
);
|
);
|
||||||
|
|
||||||
return validationErrors
|
if (validationErrors) {
|
||||||
? responses.badRequestResponse(
|
return responses.badRequestResponse(
|
||||||
"Validation failed",
|
"Validation failed",
|
||||||
formatValidationErrorsForV1Api(validationErrors),
|
formatValidationErrorsForV1Api(validationErrors),
|
||||||
true
|
true
|
||||||
)
|
);
|
||||||
: null;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const createResponseForRequest = async ({
|
|
||||||
request,
|
|
||||||
survey,
|
|
||||||
responseInputData,
|
|
||||||
country,
|
|
||||||
}: {
|
|
||||||
request: Request;
|
|
||||||
survey: TResponseSurvey;
|
|
||||||
responseInputData: TResponseInputV2;
|
|
||||||
country: string | undefined;
|
|
||||||
}): Promise<TResponseWithQuotaFull | Response> => {
|
|
||||||
const userAgent = request.headers.get("user-agent") || undefined;
|
|
||||||
const agent = new UAParser(userAgent);
|
|
||||||
|
|
||||||
|
let response: TResponseWithQuotaFull;
|
||||||
try {
|
try {
|
||||||
const meta: TResponseInputV2["meta"] = {
|
const meta: TResponseInputV2["meta"] = {
|
||||||
source: responseInputData?.meta?.source,
|
source: responseInputData?.meta?.source,
|
||||||
@@ -160,115 +139,54 @@ const createResponseForRequest = async ({
|
|||||||
device: agent.getDevice().type || "desktop",
|
device: agent.getDevice().type || "desktop",
|
||||||
os: agent.getOS().name,
|
os: agent.getOS().name,
|
||||||
},
|
},
|
||||||
country,
|
country: country,
|
||||||
action: responseInputData?.meta?.action,
|
action: responseInputData?.meta?.action,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Capture IP address if the survey has IP capture enabled
|
||||||
|
// Server-derived IP always overwrites any client-provided value
|
||||||
if (survey.isCaptureIpEnabled) {
|
if (survey.isCaptureIpEnabled) {
|
||||||
meta.ipAddress = await getClientIpFromHeaders();
|
const ipAddress = await getClientIpFromHeaders();
|
||||||
|
meta.ipAddress = ipAddress;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await createResponseWithQuotaEvaluation({
|
response = await createResponseWithQuotaEvaluation({
|
||||||
...responseInputData,
|
...responseInputData,
|
||||||
meta,
|
meta,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof InvalidInputError) {
|
if (error instanceof InvalidInputError) {
|
||||||
return responses.badRequestResponse(error.message, undefined, true);
|
return responses.badRequestResponse(error.message);
|
||||||
}
|
}
|
||||||
|
logger.error({ error, url: request.url }, "Error creating response");
|
||||||
const response = getUnexpectedPublicErrorResponse();
|
return responses.internalServerErrorResponse(
|
||||||
reportApiError({
|
error instanceof Error ? error.message : "Unknown error occurred"
|
||||||
request,
|
|
||||||
status: response.status,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OPTIONS = async (): Promise<Response> => {
|
|
||||||
return responses.successResponse(
|
|
||||||
{},
|
|
||||||
true,
|
|
||||||
// Cache CORS preflight responses for 1 hour (conservative approach)
|
|
||||||
// Balances performance gains with flexibility for CORS policy changes
|
|
||||||
"public, s-maxage=3600, max-age=3600"
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
|
||||||
const params = await context.params;
|
|
||||||
const validatedInput = await parseAndValidateResponseInput(request, params.environmentId);
|
|
||||||
|
|
||||||
if ("response" in validatedInput) {
|
|
||||||
return validatedInput.response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { environmentId, responseInputData } = validatedInput;
|
|
||||||
const country = getCountry(request.headers);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const contactsDisabledResponse = await getContactsDisabledResponse(
|
|
||||||
environmentId,
|
|
||||||
responseInputData.contactId
|
|
||||||
);
|
);
|
||||||
if (contactsDisabledResponse) {
|
}
|
||||||
return contactsDisabledResponse;
|
const { quotaFull, ...responseData } = response;
|
||||||
}
|
|
||||||
|
|
||||||
const survey = await getSurvey(responseInputData.surveyId);
|
sendToPipeline({
|
||||||
if (!survey) {
|
event: "responseCreated",
|
||||||
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
|
environmentId,
|
||||||
}
|
surveyId: responseData.surveyId,
|
||||||
|
response: responseData,
|
||||||
const validationResponse = await validateResponseSubmission(environmentId, responseInputData, survey);
|
});
|
||||||
if (validationResponse) {
|
|
||||||
return validationResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
const createdResponse = await createResponseForRequest({
|
|
||||||
request,
|
|
||||||
survey,
|
|
||||||
responseInputData,
|
|
||||||
country,
|
|
||||||
});
|
|
||||||
if (createdResponse instanceof Response) {
|
|
||||||
return createdResponse;
|
|
||||||
}
|
|
||||||
const { quotaFull, ...responseData } = createdResponse;
|
|
||||||
|
|
||||||
|
if (responseData.finished) {
|
||||||
sendToPipeline({
|
sendToPipeline({
|
||||||
event: "responseCreated",
|
event: "responseFinished",
|
||||||
environmentId,
|
environmentId,
|
||||||
surveyId: responseData.surveyId,
|
surveyId: responseData.surveyId,
|
||||||
response: responseData,
|
response: responseData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (responseData.finished) {
|
|
||||||
sendToPipeline({
|
|
||||||
event: "responseFinished",
|
|
||||||
environmentId,
|
|
||||||
surveyId: responseData.surveyId,
|
|
||||||
response: responseData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const quotaObj = createQuotaFullObject(quotaFull);
|
|
||||||
|
|
||||||
const responseDataWithQuota = {
|
|
||||||
id: responseData.id,
|
|
||||||
...quotaObj,
|
|
||||||
};
|
|
||||||
|
|
||||||
return responses.successResponse(responseDataWithQuota, true);
|
|
||||||
} catch (error) {
|
|
||||||
const response = getUnexpectedPublicErrorResponse();
|
|
||||||
reportApiError({
|
|
||||||
request,
|
|
||||||
status: response.status,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quotaObj = createQuotaFullObject(quotaFull);
|
||||||
|
|
||||||
|
const responseDataWithQuota = {
|
||||||
|
id: responseData.id,
|
||||||
|
...quotaObj,
|
||||||
|
};
|
||||||
|
|
||||||
|
return responses.successResponse(responseDataWithQuota, true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,324 +0,0 @@
|
|||||||
import { NextRequest } from "next/server";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
|
||||||
import { withV3ApiWrapper } from "./api-wrapper";
|
|
||||||
|
|
||||||
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
|
|
||||||
mockAuthenticateRequest: vi.fn(),
|
|
||||||
mockGetServerSession: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("next-auth", () => ({
|
|
||||||
getServerSession: mockGetServerSession,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/api/v1/auth", () => ({
|
|
||||||
authenticateRequest: mockAuthenticateRequest,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
|
||||||
authOptions: {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
|
||||||
applyRateLimit: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@formbricks/logger", () => ({
|
|
||||||
logger: {
|
|
||||||
withContext: vi.fn(() => ({
|
|
||||||
error: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("withV3ApiWrapper", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
mockGetServerSession.mockResolvedValue(null);
|
|
||||||
mockAuthenticateRequest.mockResolvedValue(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses session auth first in both mode and injects request id into plain responses", async () => {
|
|
||||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
|
||||||
mockGetServerSession.mockResolvedValue({
|
|
||||||
user: { id: "user_1", name: "Test", email: "t@example.com" },
|
|
||||||
expires: "2026-01-01",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handler = vi.fn(async ({ authentication, requestId, instance }) => {
|
|
||||||
expect(authentication).toMatchObject({ user: { id: "user_1" } });
|
|
||||||
expect(requestId).toBe("req-1");
|
|
||||||
expect(instance).toBe("/api/v3/surveys");
|
|
||||||
return Response.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const wrapped = withV3ApiWrapper({
|
|
||||||
auth: "both",
|
|
||||||
handler,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await wrapped(
|
|
||||||
new NextRequest("http://localhost/api/v3/surveys?limit=10", {
|
|
||||||
headers: { "x-request-id": "req-1" },
|
|
||||||
}),
|
|
||||||
{} as never
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("X-Request-Id")).toBe("req-1");
|
|
||||||
expect(handler).toHaveBeenCalledOnce();
|
|
||||||
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ namespace: "api:v3" }),
|
|
||||||
"user_1"
|
|
||||||
);
|
|
||||||
expect(mockAuthenticateRequest).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to api key auth in both mode", async () => {
|
|
||||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
|
||||||
mockAuthenticateRequest.mockResolvedValue({
|
|
||||||
type: "apiKey",
|
|
||||||
apiKeyId: "key_1",
|
|
||||||
organizationId: "org_1",
|
|
||||||
organizationAccess: { accessControl: { read: true, write: false } },
|
|
||||||
environmentPermissions: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const handler = vi.fn(async ({ authentication }) => {
|
|
||||||
expect(authentication).toMatchObject({ apiKeyId: "key_1" });
|
|
||||||
return Response.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const wrapped = withV3ApiWrapper({
|
|
||||||
auth: "both",
|
|
||||||
handler,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await wrapped(
|
|
||||||
new NextRequest("http://localhost/api/v3/surveys", {
|
|
||||||
headers: { "x-api-key": "fbk_test" },
|
|
||||||
}),
|
|
||||||
{} as never
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ namespace: "api:v3" }),
|
|
||||||
"key_1"
|
|
||||||
);
|
|
||||||
expect(mockGetServerSession).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 401 problem response when authentication is required but missing", async () => {
|
|
||||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
|
||||||
const wrapped = withV3ApiWrapper({
|
|
||||||
auth: "both",
|
|
||||||
handler,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
expect(response.headers.get("Content-Type")).toBe("application/problem+json");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 400 problem response for invalid query input", async () => {
|
|
||||||
mockGetServerSession.mockResolvedValue({
|
|
||||||
user: { id: "user_1" },
|
|
||||||
expires: "2026-01-01",
|
|
||||||
});
|
|
||||||
|
|
||||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
|
||||||
const wrapped = withV3ApiWrapper({
|
|
||||||
auth: "both",
|
|
||||||
schemas: {
|
|
||||||
query: z.object({
|
|
||||||
limit: z.coerce.number().int().positive(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
handler,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await wrapped(
|
|
||||||
new NextRequest("http://localhost/api/v3/surveys?limit=oops", {
|
|
||||||
headers: { "x-request-id": "req-invalid" },
|
|
||||||
}),
|
|
||||||
{} as never
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
const body = await response.json();
|
|
||||||
expect(body.invalid_params).toEqual(expect.arrayContaining([expect.objectContaining({ name: "limit" })]));
|
|
||||||
expect(body.requestId).toBe("req-invalid");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses body, repeated query params, and async route params", async () => {
|
|
||||||
const handler = vi.fn(async ({ parsedInput }) => {
|
|
||||||
expect(parsedInput).toEqual({
|
|
||||||
body: { name: "Survey API" },
|
|
||||||
query: { tag: ["a", "b"] },
|
|
||||||
params: { workspaceId: "ws_123" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{ ok: true },
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"X-Request-Id": "handler-request-id",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const wrapped = withV3ApiWrapper({
|
|
||||||
auth: "none",
|
|
||||||
schemas: {
|
|
||||||
body: z.object({
|
|
||||||
name: z.string(),
|
|
||||||
}),
|
|
||||||
query: z.object({
|
|
||||||
tag: z.array(z.string()),
|
|
||||||
}),
|
|
||||||
params: z.object({
|
|
||||||
workspaceId: z.string(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
handler,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await wrapped(
|
|
||||||
new NextRequest("http://localhost/api/v3/surveys?tag=a&tag=b", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ name: "Survey API" }),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
params: Promise.resolve({
|
|
||||||
workspaceId: "ws_123",
|
|
||||||
}),
|
|
||||||
} as never
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("X-Request-Id")).toBe("handler-request-id");
|
|
||||||
expect(handler).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 400 problem response for malformed JSON input", async () => {
|
|
||||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
|
||||||
const wrapped = withV3ApiWrapper({
|
|
||||||
auth: "none",
|
|
||||||
schemas: {
|
|
||||||
body: z.object({
|
|
||||||
name: z.string(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
handler,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await wrapped(
|
|
||||||
new NextRequest("http://localhost/api/v3/surveys", {
|
|
||||||
method: "POST",
|
|
||||||
body: "{",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{} as never
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
const body = await response.json();
|
|
||||||
expect(body.invalid_params).toEqual([
|
|
||||||
{
|
|
||||||
name: "body",
|
|
||||||
reason: "Malformed JSON input, please check your request body",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 400 problem response for invalid route params", async () => {
|
|
||||||
const handler = vi.fn(async () => Response.json({ ok: true }));
|
|
||||||
const wrapped = withV3ApiWrapper({
|
|
||||||
auth: "none",
|
|
||||||
schemas: {
|
|
||||||
params: z.object({
|
|
||||||
workspaceId: z.string().min(3),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
handler,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {
|
|
||||||
params: Promise.resolve({
|
|
||||||
workspaceId: "x",
|
|
||||||
}),
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
|
||||||
const body = await response.json();
|
|
||||||
expect(body.invalid_params).toEqual(
|
|
||||||
expect.arrayContaining([expect.objectContaining({ name: "workspaceId" })])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 429 problem response when rate limited", async () => {
|
|
||||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
|
||||||
mockGetServerSession.mockResolvedValue({
|
|
||||||
user: { id: "user_1" },
|
|
||||||
expires: "2026-01-01",
|
|
||||||
});
|
|
||||||
vi.mocked(applyRateLimit).mockRejectedValueOnce(new TooManyRequestsError("Too many requests", 60));
|
|
||||||
|
|
||||||
const wrapped = withV3ApiWrapper({
|
|
||||||
auth: "both",
|
|
||||||
handler: async () => Response.json({ ok: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
|
|
||||||
|
|
||||||
expect(response.status).toBe(429);
|
|
||||||
expect(response.headers.get("Retry-After")).toBe("60");
|
|
||||||
const body = await response.json();
|
|
||||||
expect(body.code).toBe("too_many_requests");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 500 problem response when the handler throws unexpectedly", async () => {
|
|
||||||
mockGetServerSession.mockResolvedValue({
|
|
||||||
user: { id: "user_1" },
|
|
||||||
expires: "2026-01-01",
|
|
||||||
});
|
|
||||||
|
|
||||||
const wrapped = withV3ApiWrapper({
|
|
||||||
auth: "both",
|
|
||||||
handler: async () => {
|
|
||||||
throw new Error("boom");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await wrapped(
|
|
||||||
new NextRequest("http://localhost/api/v3/surveys", {
|
|
||||||
headers: { "x-request-id": "req-boom" },
|
|
||||||
}),
|
|
||||||
{} as never
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
|
||||||
const body = await response.json();
|
|
||||||
expect(body.code).toBe("internal_server_error");
|
|
||||||
expect(body.requestId).toBe("req-boom");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { type NextRequest } from "next/server";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
|
||||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
|
||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
|
||||||
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
|
||||||
import {
|
|
||||||
type InvalidParam,
|
|
||||||
problemBadRequest,
|
|
||||||
problemInternalError,
|
|
||||||
problemTooManyRequests,
|
|
||||||
problemUnauthorized,
|
|
||||||
} from "./response";
|
|
||||||
import type { TV3Authentication } from "./types";
|
|
||||||
|
|
||||||
type TV3Schema = z.ZodTypeAny;
|
|
||||||
type MaybePromise<T> = T | Promise<T>;
|
|
||||||
|
|
||||||
export type TV3AuthMode = "none" | "session" | "apiKey" | "both";
|
|
||||||
|
|
||||||
export type TV3Schemas = {
|
|
||||||
body?: TV3Schema;
|
|
||||||
query?: TV3Schema;
|
|
||||||
params?: TV3Schema;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TV3ParsedInput<S extends TV3Schemas | undefined> = S extends object
|
|
||||||
? {
|
|
||||||
[K in keyof S as NonNullable<S[K]> extends TV3Schema ? K : never]: z.infer<NonNullable<S[K]>>;
|
|
||||||
}
|
|
||||||
: Record<string, never>;
|
|
||||||
|
|
||||||
export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unknown> = {
|
|
||||||
req: NextRequest;
|
|
||||||
props: TProps;
|
|
||||||
authentication: TV3Authentication;
|
|
||||||
parsedInput: TParsedInput;
|
|
||||||
requestId: string;
|
|
||||||
instance: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = unknown> = {
|
|
||||||
auth?: TV3AuthMode;
|
|
||||||
schemas?: S;
|
|
||||||
rateLimit?: boolean;
|
|
||||||
customRateLimitConfig?: TRateLimitConfig;
|
|
||||||
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
|
|
||||||
if (authMode === "session") {
|
|
||||||
return "Session required";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authMode === "apiKey") {
|
|
||||||
return "API key required";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Not authenticated";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
|
|
||||||
return error.issues.map((issue) => ({
|
|
||||||
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
|
|
||||||
reason: issue.message,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
|
|
||||||
const query: Record<string, string | string[]> = {};
|
|
||||||
|
|
||||||
for (const key of new Set(searchParams.keys())) {
|
|
||||||
const values = searchParams.getAll(key);
|
|
||||||
query[key] = values.length > 1 ? values : (values[0] ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRateLimitIdentifier(authentication: TV3Authentication): string | null {
|
|
||||||
if (!authentication) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("user" in authentication && authentication.user?.id) {
|
|
||||||
return authentication.user.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("apiKeyId" in authentication) {
|
|
||||||
return authentication.apiKeyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPromiseLike<T>(value: unknown): value is Promise<T> {
|
|
||||||
return typeof value === "object" && value !== null && "then" in value;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRouteParams<TProps>(props: TProps): Promise<Record<string, unknown>> {
|
|
||||||
if (!props || typeof props !== "object" || !("params" in props)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = (props as { params?: unknown }).params;
|
|
||||||
if (!params) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedParams = isPromiseLike<Record<string, unknown>>(params) ? await params : params;
|
|
||||||
return typeof resolvedParams === "object" && resolvedParams !== null
|
|
||||||
? (resolvedParams as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authenticateV3Request(req: NextRequest, authMode: TV3AuthMode): Promise<TV3Authentication> {
|
|
||||||
if (authMode === "none") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authMode === "both" && req.headers.has("x-api-key")) {
|
|
||||||
const apiKeyAuth = await authenticateRequest(req);
|
|
||||||
if (apiKeyAuth) {
|
|
||||||
return apiKeyAuth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authMode === "session" || authMode === "both") {
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (session?.user?.id) {
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authMode === "session") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authMode === "apiKey" || authMode === "both") {
|
|
||||||
return await authenticateRequest(req);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
|
|
||||||
req: NextRequest,
|
|
||||||
props: TProps,
|
|
||||||
schemas: S | undefined,
|
|
||||||
requestId: string,
|
|
||||||
instance: string
|
|
||||||
): Promise<
|
|
||||||
| { ok: true; parsedInput: TV3ParsedInput<S> }
|
|
||||||
| {
|
|
||||||
ok: false;
|
|
||||||
response: Response;
|
|
||||||
}
|
|
||||||
> {
|
|
||||||
const parsedInput = {} as TV3ParsedInput<S>;
|
|
||||||
|
|
||||||
if (schemas?.body) {
|
|
||||||
let bodyData: unknown;
|
|
||||||
|
|
||||||
try {
|
|
||||||
bodyData = await req.json();
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
response: problemBadRequest(requestId, "Invalid request body", {
|
|
||||||
instance,
|
|
||||||
invalid_params: [{ name: "body", reason: "Malformed JSON input, please check your request body" }],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyResult = schemas.body.safeParse(bodyData);
|
|
||||||
if (!bodyResult.success) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
response: problemBadRequest(requestId, "Invalid request body", {
|
|
||||||
instance,
|
|
||||||
invalid_params: formatZodIssues(bodyResult.error, "body"),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedInput.body = bodyResult.data as TV3ParsedInput<S>["body"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schemas?.query) {
|
|
||||||
const queryResult = schemas.query.safeParse(searchParamsToObject(req.nextUrl.searchParams));
|
|
||||||
if (!queryResult.success) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
response: problemBadRequest(requestId, "Invalid query parameters", {
|
|
||||||
instance,
|
|
||||||
invalid_params: formatZodIssues(queryResult.error, "query"),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedInput.query = queryResult.data as TV3ParsedInput<S>["query"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schemas?.params) {
|
|
||||||
const paramsResult = schemas.params.safeParse(await getRouteParams(props));
|
|
||||||
if (!paramsResult.success) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
response: problemBadRequest(requestId, "Invalid route parameters", {
|
|
||||||
instance,
|
|
||||||
invalid_params: formatZodIssues(paramsResult.error, "params"),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedInput.params = paramsResult.data as TV3ParsedInput<S>["params"];
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: true, parsedInput };
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureRequestIdHeader(response: Response, requestId: string): Response {
|
|
||||||
if (response.headers.get("X-Request-Id")) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = new Headers(response.headers);
|
|
||||||
headers.set("X-Request-Id", requestId);
|
|
||||||
|
|
||||||
return new Response(response.body, {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authenticateV3RequestOrRespond(
|
|
||||||
req: NextRequest,
|
|
||||||
authMode: TV3AuthMode,
|
|
||||||
requestId: string,
|
|
||||||
instance: string
|
|
||||||
): Promise<
|
|
||||||
{ authentication: TV3Authentication; response: null } | { authentication: null; response: Response }
|
|
||||||
> {
|
|
||||||
const authentication = await authenticateV3Request(req, authMode);
|
|
||||||
|
|
||||||
if (!authentication && authMode !== "none") {
|
|
||||||
return {
|
|
||||||
authentication: null,
|
|
||||||
response: problemUnauthorized(requestId, getUnauthenticatedDetail(authMode), instance),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
authentication,
|
|
||||||
response: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyV3RateLimitOrRespond(params: {
|
|
||||||
authentication: TV3Authentication;
|
|
||||||
enabled: boolean;
|
|
||||||
config: TRateLimitConfig;
|
|
||||||
requestId: string;
|
|
||||||
log: ReturnType<typeof logger.withContext>;
|
|
||||||
}): Promise<Response | null> {
|
|
||||||
const { authentication, enabled, config, requestId, log } = params;
|
|
||||||
if (!enabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const identifier = getRateLimitIdentifier(authentication);
|
|
||||||
if (!identifier) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await applyRateLimit(config, identifier);
|
|
||||||
} catch (error) {
|
|
||||||
log.warn({ error, statusCode: 429 }, "V3 API rate limit exceeded");
|
|
||||||
return problemTooManyRequests(
|
|
||||||
requestId,
|
|
||||||
error instanceof Error ? error.message : "Rate limit exceeded",
|
|
||||||
error instanceof TooManyRequestsError ? error.retryAfter : undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
|
|
||||||
params: TWithV3ApiWrapperParams<S, TProps>
|
|
||||||
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
|
|
||||||
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
|
|
||||||
|
|
||||||
return async (req: NextRequest, props: TProps): Promise<Response> => {
|
|
||||||
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
|
|
||||||
const instance = req.nextUrl.pathname;
|
|
||||||
const log = logger.withContext({
|
|
||||||
requestId,
|
|
||||||
method: req.method,
|
|
||||||
path: instance,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
|
|
||||||
if (authResult.response) {
|
|
||||||
log.warn({ statusCode: authResult.response.status }, "V3 API authentication failed");
|
|
||||||
return authResult.response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedInputResult = await parseV3Input(req, props, schemas, requestId, instance);
|
|
||||||
if (!parsedInputResult.ok) {
|
|
||||||
log.warn({ statusCode: parsedInputResult.response.status }, "V3 API request validation failed");
|
|
||||||
return parsedInputResult.response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rateLimitResponse = await applyV3RateLimitOrRespond({
|
|
||||||
authentication: authResult.authentication,
|
|
||||||
enabled: rateLimit,
|
|
||||||
config: customRateLimitConfig ?? rateLimitConfigs.api.v3,
|
|
||||||
requestId,
|
|
||||||
log,
|
|
||||||
});
|
|
||||||
if (rateLimitResponse) {
|
|
||||||
return rateLimitResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await handler({
|
|
||||||
req,
|
|
||||||
props,
|
|
||||||
authentication: authResult.authentication,
|
|
||||||
parsedInput: parsedInputResult.parsedInput,
|
|
||||||
requestId,
|
|
||||||
instance,
|
|
||||||
});
|
|
||||||
|
|
||||||
return ensureRequestIdHeader(response, requestId);
|
|
||||||
} catch (error) {
|
|
||||||
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
|
|
||||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
|
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
|
||||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
|
||||||
import { getEnvironment } from "@/lib/utils/services";
|
|
||||||
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
|
|
||||||
|
|
||||||
vi.mock("@formbricks/logger", () => ({
|
|
||||||
logger: {
|
|
||||||
withContext: vi.fn(() => ({
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/helper", () => ({
|
|
||||||
getOrganizationIdFromProjectId: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/services", () => ({
|
|
||||||
getEnvironment: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
|
||||||
checkAuthorizationUpdated: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const requestId = "req-123";
|
|
||||||
|
|
||||||
describe("requireSessionWorkspaceAccess", () => {
|
|
||||||
test("returns 401 when authentication is null", async () => {
|
|
||||||
const result = await requireSessionWorkspaceAccess(null, "proj_abc", "read", requestId);
|
|
||||||
expect(result).toBeInstanceOf(Response);
|
|
||||||
expect((result as Response).status).toBe(401);
|
|
||||||
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
|
|
||||||
const body = await (result as Response).json();
|
|
||||||
expect(body.requestId).toBe(requestId);
|
|
||||||
expect(body.status).toBe(401);
|
|
||||||
expect(body.code).toBe("not_authenticated");
|
|
||||||
expect(getEnvironment).not.toHaveBeenCalled();
|
|
||||||
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 401 when authentication is API key (no user)", async () => {
|
|
||||||
const result = await requireSessionWorkspaceAccess(
|
|
||||||
{ apiKeyId: "key_1", organizationId: "org_1", environmentPermissions: [] } as any,
|
|
||||||
"proj_abc",
|
|
||||||
"read",
|
|
||||||
requestId
|
|
||||||
);
|
|
||||||
expect(result).toBeInstanceOf(Response);
|
|
||||||
expect((result as Response).status).toBe(401);
|
|
||||||
const body = await (result as Response).json();
|
|
||||||
expect(body.requestId).toBe(requestId);
|
|
||||||
expect(body.code).toBe("not_authenticated");
|
|
||||||
expect(getEnvironment).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 403 when workspace (environment) is not found (avoid leaking existence)", async () => {
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
|
||||||
const result = await requireSessionWorkspaceAccess(
|
|
||||||
{ user: { id: "user_1" }, expires: "" } as any,
|
|
||||||
"env_nonexistent",
|
|
||||||
"read",
|
|
||||||
requestId
|
|
||||||
);
|
|
||||||
expect(result).toBeInstanceOf(Response);
|
|
||||||
expect((result as Response).status).toBe(403);
|
|
||||||
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
|
|
||||||
const body = await (result as Response).json();
|
|
||||||
expect(body.requestId).toBe(requestId);
|
|
||||||
expect(body.code).toBe("forbidden");
|
|
||||||
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
|
|
||||||
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 403 when user has no access to workspace", async () => {
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce({
|
|
||||||
id: "env_abc",
|
|
||||||
projectId: "proj_abc",
|
|
||||||
} as any);
|
|
||||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
|
|
||||||
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
|
||||||
const result = await requireSessionWorkspaceAccess(
|
|
||||||
{ user: { id: "user_1" }, expires: "" } as any,
|
|
||||||
"env_abc",
|
|
||||||
"read",
|
|
||||||
requestId
|
|
||||||
);
|
|
||||||
expect(result).toBeInstanceOf(Response);
|
|
||||||
expect((result as Response).status).toBe(403);
|
|
||||||
const body = await (result as Response).json();
|
|
||||||
expect(body.requestId).toBe(requestId);
|
|
||||||
expect(body.code).toBe("forbidden");
|
|
||||||
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
|
|
||||||
userId: "user_1",
|
|
||||||
organizationId: "org_1",
|
|
||||||
access: [
|
|
||||||
{ type: "organization", roles: ["owner", "manager"] },
|
|
||||||
{ type: "projectTeam", projectId: "proj_abc", minPermission: "read" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns workspace context when session is valid and user has access", async () => {
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce({
|
|
||||||
id: "env_abc",
|
|
||||||
projectId: "proj_abc",
|
|
||||||
} as any);
|
|
||||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
|
|
||||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
|
|
||||||
const result = await requireSessionWorkspaceAccess(
|
|
||||||
{ user: { id: "user_1" }, expires: "" } as any,
|
|
||||||
"env_abc",
|
|
||||||
"readWrite",
|
|
||||||
requestId
|
|
||||||
);
|
|
||||||
expect(result).not.toBeInstanceOf(Response);
|
|
||||||
expect(result).toEqual({
|
|
||||||
environmentId: "env_abc",
|
|
||||||
projectId: "proj_abc",
|
|
||||||
organizationId: "org_1",
|
|
||||||
});
|
|
||||||
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
|
|
||||||
userId: "user_1",
|
|
||||||
organizationId: "org_1",
|
|
||||||
access: [
|
|
||||||
{ type: "organization", roles: ["owner", "manager"] },
|
|
||||||
{ type: "projectTeam", projectId: "proj_abc", minPermission: "readWrite" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const keyBase = {
|
|
||||||
type: "apiKey" as const,
|
|
||||||
apiKeyId: "key_1",
|
|
||||||
organizationId: "org_k",
|
|
||||||
organizationAccess: { accessControl: { read: true, write: false } },
|
|
||||||
};
|
|
||||||
|
|
||||||
function envPerm(environmentId: string, permission: ApiKeyPermission = ApiKeyPermission.read) {
|
|
||||||
return {
|
|
||||||
environmentId,
|
|
||||||
environmentType: EnvironmentType.development,
|
|
||||||
projectId: "proj_k",
|
|
||||||
projectName: "K",
|
|
||||||
permission,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("requireV3WorkspaceAccess", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValue({
|
|
||||||
id: "env_k",
|
|
||||||
projectId: "proj_k",
|
|
||||||
} as any);
|
|
||||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValue("org_k");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("401 when authentication is null", async () => {
|
|
||||||
const r = await requireV3WorkspaceAccess(null, "env_x", "read", requestId);
|
|
||||||
expect((r as Response).status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("delegates to session flow when user is present", async () => {
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce({
|
|
||||||
id: "env_s",
|
|
||||||
projectId: "proj_s",
|
|
||||||
} as any);
|
|
||||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_s");
|
|
||||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
|
|
||||||
const r = await requireV3WorkspaceAccess(
|
|
||||||
{ user: { id: "user_1" }, expires: "" } as any,
|
|
||||||
"env_s",
|
|
||||||
"read",
|
|
||||||
requestId
|
|
||||||
);
|
|
||||||
expect(r).toEqual({
|
|
||||||
environmentId: "env_s",
|
|
||||||
projectId: "proj_s",
|
|
||||||
organizationId: "org_s",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns context for API key with read on workspace", async () => {
|
|
||||||
const auth = {
|
|
||||||
...keyBase,
|
|
||||||
environmentPermissions: [envPerm("ws_a", ApiKeyPermission.read)],
|
|
||||||
};
|
|
||||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_a", "read", requestId);
|
|
||||||
expect(r).toEqual({
|
|
||||||
environmentId: "ws_a",
|
|
||||||
projectId: "proj_k",
|
|
||||||
organizationId: "org_k",
|
|
||||||
});
|
|
||||||
expect(getEnvironment).toHaveBeenCalledWith("ws_a");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns context for API key with write on workspace", async () => {
|
|
||||||
const auth = {
|
|
||||||
...keyBase,
|
|
||||||
environmentPermissions: [envPerm("ws_b", ApiKeyPermission.write)],
|
|
||||||
};
|
|
||||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_b", "read", requestId);
|
|
||||||
expect(r).toEqual({
|
|
||||||
environmentId: "ws_b",
|
|
||||||
projectId: "proj_k",
|
|
||||||
organizationId: "org_k",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 403 when API key permission is lower than the required permission", async () => {
|
|
||||||
const auth = {
|
|
||||||
...keyBase,
|
|
||||||
environmentPermissions: [envPerm("ws_write", ApiKeyPermission.read)],
|
|
||||||
};
|
|
||||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_write", "readWrite", requestId);
|
|
||||||
expect((r as Response).status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("403 when API key has no matching environment", async () => {
|
|
||||||
const auth = {
|
|
||||||
...keyBase,
|
|
||||||
environmentPermissions: [envPerm("other_env")],
|
|
||||||
};
|
|
||||||
const r = await requireV3WorkspaceAccess(auth as any, "wanted", "read", requestId);
|
|
||||||
expect((r as Response).status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("403 when API key permission is not list-eligible (runtime value)", async () => {
|
|
||||||
const auth = {
|
|
||||||
...keyBase,
|
|
||||||
environmentPermissions: [
|
|
||||||
{
|
|
||||||
...envPerm("ws_c"),
|
|
||||||
permission: "invalid" as unknown as ApiKeyPermission,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_c", "read", requestId);
|
|
||||||
expect((r as Response).status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns context for API key with manage on workspace", async () => {
|
|
||||||
const auth = {
|
|
||||||
...keyBase,
|
|
||||||
environmentPermissions: [envPerm("ws_m", ApiKeyPermission.manage)],
|
|
||||||
};
|
|
||||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_m", "manage", requestId);
|
|
||||||
expect(r).toEqual({
|
|
||||||
environmentId: "ws_m",
|
|
||||||
projectId: "proj_k",
|
|
||||||
organizationId: "org_k",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
|
||||||
const auth = {
|
|
||||||
...keyBase,
|
|
||||||
environmentPermissions: [envPerm("ws_missing", ApiKeyPermission.manage)],
|
|
||||||
};
|
|
||||||
const r = await requireV3WorkspaceAccess(auth as any, "ws_missing", "read", requestId);
|
|
||||||
expect((r as Response).status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("401 when auth is neither session nor valid API key payload", async () => {
|
|
||||||
const r = await requireV3WorkspaceAccess({ user: {} } as any, "env", "read", requestId);
|
|
||||||
expect((r as Response).status).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* V3 API auth — session (browser) or API key with environment-scoped access.
|
|
||||||
*/
|
|
||||||
import { ApiKeyPermission } from "@prisma/client";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
|
||||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
|
||||||
import type { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
|
||||||
import { problemForbidden, problemUnauthorized } from "./response";
|
|
||||||
import type { TV3Authentication } from "./types";
|
|
||||||
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
|
|
||||||
|
|
||||||
function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTeamPermission): boolean {
|
|
||||||
const grantedRank = {
|
|
||||||
[ApiKeyPermission.read]: 1,
|
|
||||||
[ApiKeyPermission.write]: 2,
|
|
||||||
[ApiKeyPermission.manage]: 3,
|
|
||||||
}[permission];
|
|
||||||
|
|
||||||
const requiredRank = {
|
|
||||||
read: 1,
|
|
||||||
readWrite: 2,
|
|
||||||
manage: 3,
|
|
||||||
}[minPermission];
|
|
||||||
|
|
||||||
return grantedRank >= requiredRank;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer.
|
|
||||||
* Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers
|
|
||||||
* use internal IDs (environmentId, projectId, organizationId) without resolving again.
|
|
||||||
* We use 403 (not 404) when the workspace is not found to avoid leaking resource existence.
|
|
||||||
*/
|
|
||||||
export async function requireSessionWorkspaceAccess(
|
|
||||||
authentication: TV3Authentication,
|
|
||||||
workspaceId: string,
|
|
||||||
minPermission: TTeamPermission,
|
|
||||||
requestId: string,
|
|
||||||
instance?: string
|
|
||||||
): Promise<Response | V3WorkspaceContext> {
|
|
||||||
// --- Session checks ---
|
|
||||||
if (!authentication) {
|
|
||||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
|
||||||
}
|
|
||||||
if (!("user" in authentication) || !authentication.user?.id) {
|
|
||||||
return problemUnauthorized(requestId, "Session required", instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = authentication.user.id;
|
|
||||||
const log = logger.withContext({ requestId, workspaceId });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Resolve workspaceId → environmentId, projectId, organizationId (single place to change when Workspace exists).
|
|
||||||
const context = await resolveV3WorkspaceContext(workspaceId);
|
|
||||||
|
|
||||||
// Org + project-team access; we use internal IDs from context.
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId,
|
|
||||||
organizationId: context.organizationId,
|
|
||||||
access: [
|
|
||||||
{ type: "organization", roles: ["owner", "manager"] },
|
|
||||||
{ type: "projectTeam", projectId: context.projectId, minPermission },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return context;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ResourceNotFoundError || err instanceof AuthorizationError) {
|
|
||||||
const message = err instanceof ResourceNotFoundError ? "Workspace not found" : "Forbidden";
|
|
||||||
log.warn({ statusCode: 403, errorCode: err.name }, message);
|
|
||||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Session or API key: authorize `workspaceId` against the resolved V3 workspace context. */
|
|
||||||
export async function requireV3WorkspaceAccess(
|
|
||||||
authentication: TV3Authentication,
|
|
||||||
workspaceId: string,
|
|
||||||
minPermission: TTeamPermission,
|
|
||||||
requestId: string,
|
|
||||||
instance?: string
|
|
||||||
): Promise<Response | V3WorkspaceContext> {
|
|
||||||
if (!authentication) {
|
|
||||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ("user" in authentication && authentication.user?.id) {
|
|
||||||
return requireSessionWorkspaceAccess(authentication, workspaceId, minPermission, requestId, instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyAuth = authentication as TAuthenticationApiKey;
|
|
||||||
if (keyAuth.apiKeyId && Array.isArray(keyAuth.environmentPermissions)) {
|
|
||||||
const log = logger.withContext({ requestId, workspaceId, apiKeyId: keyAuth.apiKeyId });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const context = await resolveV3WorkspaceContext(workspaceId);
|
|
||||||
const permission = keyAuth.environmentPermissions.find(
|
|
||||||
(environmentPermission) => environmentPermission.environmentId === context.environmentId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) {
|
|
||||||
log.warn({ statusCode: 403 }, "API key not allowed for workspace");
|
|
||||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ResourceNotFoundError) {
|
|
||||||
log.warn({ statusCode: 403, errorCode: error.name }, "Workspace not found");
|
|
||||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user