mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-27 08:50:38 -06:00
Compare commits
13 Commits
feat-reset
...
hmaan-disp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
249e42b087 | ||
|
|
ba9b01a969 | ||
|
|
e810e38333 | ||
|
|
dab8ad00d5 | ||
|
|
9ec4f22b1f | ||
|
|
ea00dec8e5 | ||
|
|
2c34f43c83 | ||
|
|
979fd71a11 | ||
|
|
1be23eebbb | ||
|
|
d10cff917d | ||
|
|
da72101320 | ||
|
|
70cd751b0e | ||
|
|
4308d86bbc |
@@ -210,6 +210,8 @@ UNKEY_ROOT_KEY=
|
||||
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
|
||||
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard
|
||||
# SENTRY_ENVIRONMENT=
|
||||
|
||||
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
|
||||
121
.github/actions/upload-sentry-sourcemaps/action.yml
vendored
Normal file
121
.github/actions/upload-sentry-sourcemaps/action.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
name: 'Upload Sentry Sourcemaps'
|
||||
description: 'Extract sourcemaps from Docker image and upload to Sentry'
|
||||
|
||||
inputs:
|
||||
docker_image:
|
||||
description: 'Docker image to extract sourcemaps from'
|
||||
required: true
|
||||
release_version:
|
||||
description: 'Sentry release version (e.g., v1.2.3)'
|
||||
required: true
|
||||
sentry_auth_token:
|
||||
description: 'Sentry authentication token'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate Sentry auth token
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "🔐 Validating Sentry authentication token..."
|
||||
|
||||
# Assign token to local variable for secure handling
|
||||
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
|
||||
|
||||
# Test the token by making a simple API call to Sentry
|
||||
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
|
||||
-H "Authorization: Bearer $SENTRY_TOKEN" \
|
||||
"https://sentry.io/api/0/organizations/formbricks/")
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
|
||||
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
|
||||
if [ -f /tmp/sentry_response.json ]; then
|
||||
echo "Response body:"
|
||||
cat /tmp/sentry_response.json
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Sentry auth token validated successfully"
|
||||
|
||||
# Clean up temp file
|
||||
rm -f /tmp/sentry_response.json
|
||||
|
||||
- name: Extract sourcemaps from Docker image
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
|
||||
|
||||
# Create temporary container from the image and capture its ID
|
||||
echo "Creating temporary container..."
|
||||
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
|
||||
echo "Container created with ID: $CONTAINER_ID"
|
||||
|
||||
# Set up cleanup function to ensure container is removed on script exit
|
||||
cleanup_container() {
|
||||
# Capture the current exit code to preserve it
|
||||
local original_exit_code=$?
|
||||
|
||||
echo "🧹 Cleaning up Docker container..."
|
||||
|
||||
# Remove the container if it exists (ignore errors if already removed)
|
||||
if [ -n "$CONTAINER_ID" ]; then
|
||||
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
|
||||
echo "Container $CONTAINER_ID removed"
|
||||
fi
|
||||
|
||||
# Exit with the original exit code to preserve script success/failure status
|
||||
exit $original_exit_code
|
||||
}
|
||||
|
||||
# Register cleanup function to run on script exit (success or failure)
|
||||
trap cleanup_container EXIT
|
||||
|
||||
# Extract .next directory containing sourcemaps
|
||||
docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next
|
||||
|
||||
# Verify sourcemaps exist
|
||||
if [ ! -d "./extracted-next/static/chunks" ]; then
|
||||
echo "❌ Error: .next/static/chunks directory not found in Docker image"
|
||||
echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
|
||||
echo "✅ Found $sourcemap_count sourcemap files"
|
||||
|
||||
if [ "$sourcemap_count" -eq 0 ]; then
|
||||
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Sentry release and upload sourcemaps
|
||||
uses: getsentry/action-release@v3
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }}
|
||||
SENTRY_ORG: formbricks
|
||||
SENTRY_PROJECT: formbricks-cloud
|
||||
with:
|
||||
environment: production
|
||||
version: ${{ inputs.release_version }}
|
||||
sourcemaps: './extracted-next/'
|
||||
|
||||
- name: Clean up extracted files
|
||||
shell: bash
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Clean up extracted files
|
||||
rm -rf ./extracted-next
|
||||
echo "🧹 Cleaned up extracted files"
|
||||
22
.github/workflows/formbricks-release.yml
vendored
22
.github/workflows/formbricks-release.yml
vendored
@@ -32,3 +32,25 @@ jobs:
|
||||
with:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: "prod"
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- docker-build
|
||||
- deploy-formbricks-cloud
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Upload Sentry Sourcemaps
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
46
.github/workflows/upload-sentry-sourcemaps.yml
vendored
Normal file
46
.github/workflows/upload-sentry-sourcemaps.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Upload Sentry Sourcemaps (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
docker_image:
|
||||
description: "Docker image to extract sourcemaps from"
|
||||
required: true
|
||||
type: string
|
||||
release_version:
|
||||
description: "Release version (e.g., v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
tag_version:
|
||||
description: "Docker image tag (leave empty to use release_version)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upload-sourcemaps:
|
||||
name: Upload Sourcemaps to Sentry
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set Docker Image
|
||||
run: |
|
||||
if [ -n "${{ inputs.tag_version }}" ]; then
|
||||
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Upload Sourcemaps to Sentry
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
with:
|
||||
docker_image: ${{ env.DOCKER_IMAGE }}
|
||||
release_version: ${{ inputs.release_version }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
@@ -25,21 +25,9 @@ RUN corepack prepare pnpm@9.15.9 --activate
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
|
||||
# BuildKit secret handling without hardcoded fallback values
|
||||
# This approach relies entirely on secrets passed from GitHub Actions
|
||||
RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
|
||||
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
|
||||
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
|
||||
echo 'else' >> /tmp/read-secrets.sh && \
|
||||
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
|
||||
echo 'fi' >> /tmp/read-secrets.sh && \
|
||||
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
|
||||
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
|
||||
echo 'else' >> /tmp/read-secrets.sh && \
|
||||
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
|
||||
echo 'fi' >> /tmp/read-secrets.sh && \
|
||||
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
|
||||
chmod +x /tmp/read-secrets.sh
|
||||
# Copy the secrets handling script
|
||||
COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
|
||||
RUN chmod +x /tmp/read-secrets.sh
|
||||
|
||||
# Increase Node.js memory limit as a regular build argument
|
||||
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||
@@ -62,6 +50,9 @@ RUN touch apps/web/.env
|
||||
# Install the dependencies
|
||||
RUN pnpm install --ignore-scripts
|
||||
|
||||
# Build the database package first
|
||||
RUN pnpm build --filter=@formbricks/database
|
||||
|
||||
# Build the project using our secret reader script
|
||||
# This mounts the secrets only during this build step without storing them in layers
|
||||
RUN --mount=type=secret,id=database_url \
|
||||
@@ -106,20 +97,8 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
|
||||
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
|
||||
|
||||
COPY --from=installer /app/packages/database/migration ./packages/database/migration
|
||||
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
|
||||
|
||||
COPY --from=installer /app/packages/database/src ./packages/database/src
|
||||
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
|
||||
|
||||
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
|
||||
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
|
||||
|
||||
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
|
||||
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
|
||||
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
||||
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
||||
|
||||
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
||||
@@ -142,12 +121,14 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
|
||||
RUN npm install -g prisma
|
||||
|
||||
# Create a startup script to handle the conditional logic
|
||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||
RUN chown nextjs:nextjs /home/nextjs/start.sh && chmod +x /home/nextjs/start.sh
|
||||
|
||||
EXPOSE 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV NODE_ENV="production"
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
# Prepare volume for uploads
|
||||
@@ -158,12 +139,4 @@ VOLUME /home/nextjs/apps/web/uploads/
|
||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
|
||||
echo "Starting cron jobs..."; \
|
||||
supercronic -quiet /app/docker/cronjobs & \
|
||||
else \
|
||||
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
|
||||
fi; \
|
||||
(cd packages/database && npm run db:migrate:deploy) && \
|
||||
(cd packages/database && npm run db:create-saml-database:deploy) && \
|
||||
exec node apps/web/server.js
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
@@ -94,6 +94,7 @@ describe("LandingSidebar component", () => {
|
||||
organizationId: "o1",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,6 +130,7 @@ export const LandingSidebar = ({
|
||||
organizationId: organization.id,
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
|
||||
@@ -221,7 +221,6 @@ describe("MainNavigation", () => {
|
||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||
|
||||
// Set up localStorage spy on the mocked localStorage
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
@@ -243,23 +242,18 @@ describe("MainNavigation", () => {
|
||||
const logoutButton = screen.getByText("common.logout");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
// Verify localStorage.removeItem is called with the correct key
|
||||
expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id");
|
||||
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "org1",
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
// Clean up spy
|
||||
removeItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("handles organization switching", async () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
@@ -391,14 +390,13 @@ export const MainNavigation = ({
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
||||
}}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendVerificationNewEmail } from "@/modules/email";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
@@ -162,3 +162,21 @@ export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatar
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export const resetPasswordAction = authenticatedActionClient.action(
|
||||
withAuditLogging(
|
||||
"passwordReset",
|
||||
"user",
|
||||
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(ctx.user);
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { forgotPasswordAction } from "@/modules/auth/forgot-password/actions";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { updateUserAction } from "../actions";
|
||||
import { resetPasswordAction, updateUserAction } from "../actions";
|
||||
import { EditProfileDetailsForm } from "./EditProfileDetailsForm";
|
||||
|
||||
const mockUser = {
|
||||
@@ -38,6 +37,7 @@ beforeEach(() => {
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
|
||||
updateUserAction: vi.fn(),
|
||||
resetPasswordAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/actions", () => ({
|
||||
@@ -144,7 +144,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
});
|
||||
|
||||
test("reset password button works", async () => {
|
||||
vi.mocked(forgotPasswordAction).mockResolvedValue(undefined);
|
||||
vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } });
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
@@ -158,8 +158,9 @@ describe("EditProfileDetailsForm", () => {
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(forgotPasswordAction).toHaveBeenCalledWith({ email: mockUser.email });
|
||||
expect(resetPasswordAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading");
|
||||
});
|
||||
@@ -167,7 +168,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
|
||||
test("reset password button handles error correctly", async () => {
|
||||
const errorMessage = "Reset failed";
|
||||
vi.mocked(forgotPasswordAction).mockRejectedValue(new Error(errorMessage));
|
||||
vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage });
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
@@ -181,12 +182,16 @@ describe("EditProfileDetailsForm", () => {
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(forgotPasswordAction).toHaveBeenCalledWith({ email: mockUser.email });
|
||||
expect(resetPasswordAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
test("reset password button shows loading state", async () => {
|
||||
vi.mocked(forgotPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
||||
import { appLanguages } from "@/lib/i18n/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { forgotPasswordAction } from "@/modules/auth/forgot-password/actions";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -24,7 +23,7 @@ import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||
import { updateUserAction } from "../actions";
|
||||
import { resetPasswordAction, updateUserAction } from "../actions";
|
||||
|
||||
// Schema & types
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
|
||||
@@ -98,6 +97,7 @@ export const EditProfileDetailsForm = ({
|
||||
redirectUrl: "/email-change-without-verification-success",
|
||||
redirect: true,
|
||||
callbackUrl: "/email-change-without-verification-success",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -130,19 +130,23 @@ export const EditProfileDetailsForm = ({
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!user.email) return;
|
||||
|
||||
setIsResettingPassword(true);
|
||||
|
||||
await forgotPasswordAction({ email: user.email });
|
||||
const result = await resetPasswordAction();
|
||||
if (result?.data) {
|
||||
toast.success(t("auth.forgot-password.email-sent.heading"));
|
||||
|
||||
toast.success(t("auth.forgot-password.email-sent.heading"));
|
||||
await signOutWithAudit({
|
||||
reason: "password_reset",
|
||||
redirectUrl: "/auth/login",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
});
|
||||
await signOutWithAudit({
|
||||
reason: "password_reset",
|
||||
redirectUrl: "/auth/login",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(t(errorMessage));
|
||||
}
|
||||
|
||||
setIsResettingPassword(false);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getDisplay } from "@/lib/display/service";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -97,6 +98,14 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
return responses.badRequestResponse("Invalid file upload response");
|
||||
}
|
||||
|
||||
// check display
|
||||
if (responseInputData.displayId) {
|
||||
const display = await getDisplay(responseInputData.displayId);
|
||||
if (!display) {
|
||||
return responses.notFoundResponse("Display", responseInputData.displayId, true);
|
||||
}
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta: TResponseInput["meta"] = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/respons
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getDisplay } from "@/lib/display/service";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
|
||||
@@ -104,6 +105,14 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
);
|
||||
}
|
||||
|
||||
// check display
|
||||
if (responseInputData.displayId) {
|
||||
const display = await getDisplay(responseInputData.displayId);
|
||||
if (!display) {
|
||||
return responses.notFoundResponse("Display", responseInputData.displayId, true);
|
||||
}
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta: TResponseInputV2["meta"] = {
|
||||
|
||||
@@ -31,6 +31,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SENTRY_RELEASE: "mock-sentry-release",
|
||||
SENTRY_ENVIRONMENT: "mock-sentry-environment",
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/language", () => ({
|
||||
@@ -59,9 +61,18 @@ vi.mock("@/tolgee/client", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/app/sentry/SentryProvider", () => ({
|
||||
SentryProvider: ({ children, sentryDsn }: { children: React.ReactNode; sentryDsn?: string }) => (
|
||||
SentryProvider: ({
|
||||
children,
|
||||
sentryDsn,
|
||||
sentryRelease,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
sentryDsn?: string;
|
||||
sentryRelease?: string;
|
||||
}) => (
|
||||
<div data-testid="sentry-provider">
|
||||
SentryProvider: {sentryDsn}
|
||||
{sentryRelease && ` - Release: ${sentryRelease}`}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SentryProvider } from "@/app/sentry/SentryProvider";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { IS_PRODUCTION, SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants";
|
||||
import { TolgeeNextProvider } from "@/tolgee/client";
|
||||
import { getLocale } from "@/tolgee/language";
|
||||
import { getTolgee } from "@/tolgee/server";
|
||||
@@ -25,7 +25,11 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html lang={locale} translate="no">
|
||||
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
||||
<SentryProvider sentryDsn={SENTRY_DSN} isEnabled={IS_PRODUCTION}>
|
||||
<SentryProvider
|
||||
sentryDsn={SENTRY_DSN}
|
||||
sentryRelease={SENTRY_RELEASE}
|
||||
sentryEnvironment={SENTRY_ENVIRONMENT}
|
||||
isEnabled={IS_PRODUCTION}>
|
||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||
{children}
|
||||
</TolgeeNextProvider>
|
||||
|
||||
@@ -48,6 +48,24 @@ describe("SentryProvider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("calls Sentry.init with sentryRelease when provided", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
const testRelease = "v1.2.3";
|
||||
|
||||
render(
|
||||
<SentryProvider sentryDsn={sentryDsn} sentryRelease={testRelease} isEnabled>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
expect(initSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dsn: sentryDsn,
|
||||
release: testRelease,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("does not call Sentry.init when sentryDsn is not provided", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
|
||||
@@ -6,14 +6,24 @@ import { useEffect } from "react";
|
||||
interface SentryProviderProps {
|
||||
children: React.ReactNode;
|
||||
sentryDsn?: string;
|
||||
sentryRelease?: string;
|
||||
sentryEnvironment?: string;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProviderProps) => {
|
||||
export const SentryProvider = ({
|
||||
children,
|
||||
sentryDsn,
|
||||
sentryRelease,
|
||||
sentryEnvironment,
|
||||
isEnabled,
|
||||
}: SentryProviderProps) => {
|
||||
useEffect(() => {
|
||||
if (sentryDsn && isEnabled) {
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
release: sentryRelease,
|
||||
environment: sentryEnvironment,
|
||||
|
||||
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
|
||||
tracesSampleRate: 0,
|
||||
|
||||
@@ -233,8 +233,8 @@ export enum STRIPE_PROJECT_NAMES {
|
||||
}
|
||||
|
||||
export enum STRIPE_PRICE_LOOKUP_KEYS {
|
||||
STARTUP_MONTHLY = "formbricks_startup_monthly",
|
||||
STARTUP_YEARLY = "formbricks_startup_yearly",
|
||||
STARTUP_MAY25_MONTHLY = "STARTUP_MAY25_MONTHLY",
|
||||
STARTUP_MAY25_YEARLY = "STARTUP_MAY25_YEARLY",
|
||||
SCALE_MONTHLY = "formbricks_scale_monthly",
|
||||
SCALE_YEARLY = "formbricks_scale_yearly",
|
||||
}
|
||||
@@ -273,6 +273,24 @@ export const RECAPTCHA_SITE_KEY = env.RECAPTCHA_SITE_KEY;
|
||||
export const RECAPTCHA_SECRET_KEY = env.RECAPTCHA_SECRET_KEY;
|
||||
export const IS_RECAPTCHA_CONFIGURED = Boolean(RECAPTCHA_SITE_KEY && RECAPTCHA_SECRET_KEY);
|
||||
|
||||
// Use the app version for Sentry release (updated during build in production)
|
||||
// Fallback to environment variable if package.json is not accessible
|
||||
export const SENTRY_RELEASE = (() => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Try to read from package.json with proper error handling
|
||||
try {
|
||||
const pkg = require("../package.json");
|
||||
return pkg.version === "0.0.0" ? undefined : `v${pkg.version}`;
|
||||
} catch {
|
||||
// If package.json can't be read (e.g., in some deployment scenarios),
|
||||
// return undefined and let Sentry work without release tracking
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
export const SENTRY_ENVIRONMENT = env.SENTRY_ENVIRONMENT;
|
||||
export const SENTRY_DSN = env.SENTRY_DSN;
|
||||
|
||||
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
|
||||
|
||||
@@ -62,3 +62,19 @@ export const deleteDisplay = async (displayId: string): Promise<TDisplay> => {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDisplay = reactCache(async (displayId: string): Promise<{ id: string } | null> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
try {
|
||||
const display = await prisma.display.findUnique({
|
||||
where: { id: displayId },
|
||||
select: { id: true },
|
||||
});
|
||||
return display;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -127,6 +127,7 @@ export const env = createEnv({
|
||||
.string()
|
||||
.transform((val) => parseInt(val))
|
||||
.optional(),
|
||||
SENTRY_ENVIRONMENT: z.string().optional(),
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -225,5 +226,6 @@ export const env = createEnv({
|
||||
AUDIT_LOG_ENABLED: process.env.AUDIT_LOG_ENABLED,
|
||||
AUDIT_LOG_GET_USER_IP: process.env.AUDIT_LOG_GET_USER_IP,
|
||||
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
|
||||
SENTRY_ENVIRONMENT: process.env.SENTRY_ENVIRONMENT,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -316,6 +316,7 @@
|
||||
"remove": "Entfernen",
|
||||
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
|
||||
"report_survey": "Umfrage melden",
|
||||
"request_pricing": "Preise anfragen",
|
||||
"request_trial_license": "Testlizenz anfordern",
|
||||
"reset_to_default": "Auf Standard zurücksetzen",
|
||||
"response": "Antwort",
|
||||
@@ -979,63 +980,53 @@
|
||||
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10,000 monatliche Antworten",
|
||||
"1500_monthly_responses": "1,500 monatliche Antworten",
|
||||
"2000_monthly_identified_users": "2,000 monatlich identifizierte Nutzer",
|
||||
"30000_monthly_identified_users": "30,000 monatlich identifizierte Nutzer",
|
||||
"1000_monthly_responses": "1,000 monatliche Antworten",
|
||||
"1_project": "1 Projekt",
|
||||
"2000_contacts": "2,000 Kontakte",
|
||||
"3_projects": "3 Projekte",
|
||||
"5000_monthly_responses": "5,000 monatliche Antworten",
|
||||
"5_projects": "5 Projekte",
|
||||
"7500_monthly_identified_users": "7,500 monatlich identifizierte Nutzer",
|
||||
"advanced_targeting": "Erweitertes Targeting",
|
||||
"7500_contacts": "7,500 Kontakte",
|
||||
"all_integrations": "Alle Integrationen",
|
||||
"all_surveying_features": "Alle Umfragefunktionen",
|
||||
"annually": "Jährlich",
|
||||
"api_webhooks": "API & Webhooks",
|
||||
"app_surveys": "In-app Umfragen",
|
||||
"contact_us": "Kontaktiere uns",
|
||||
"attribute_based_targeting": "Attributbasiertes Targeting",
|
||||
"current": "aktuell",
|
||||
"current_plan": "Aktueller Plan",
|
||||
"current_tier_limit": "Aktuelles Limit",
|
||||
"custom_miu_limit": "Benutzerdefiniertes MIU-Limit",
|
||||
"custom": "Benutzerdefiniert & Skalierung",
|
||||
"custom_contacts_limit": "Benutzerdefiniertes Kontaktlimit",
|
||||
"custom_project_limit": "Benutzerdefiniertes Projektlimit",
|
||||
"customer_success_manager": "Customer Success Manager",
|
||||
"custom_response_limit": "Benutzerdefiniertes Antwortlimit",
|
||||
"email_embedded_surveys": "Eingebettete Umfragen in E-Mails",
|
||||
"email_support": "E-Mail-Support",
|
||||
"enterprise": "Enterprise",
|
||||
"email_follow_ups": "E-Mail Follow-ups",
|
||||
"enterprise_description": "Premium-Support und benutzerdefinierte Limits.",
|
||||
"everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!",
|
||||
"everything_in_free": "Alles in 'Free''",
|
||||
"everything_in_scale": "Alles in 'Scale''",
|
||||
"everything_in_startup": "Alles in 'Startup''",
|
||||
"free": "Kostenlos",
|
||||
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
|
||||
"get_2_months_free": "2 Monate gratis",
|
||||
"get_in_touch": "Kontaktiere uns",
|
||||
"hosted_in_frankfurt": "Gehostet in Frankfurt",
|
||||
"ios_android_sdks": "iOS & Android SDK für mobile Umfragen",
|
||||
"link_surveys": "Umfragen verlinken (teilbar)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.",
|
||||
"manage_card_details": "Karteninformationen verwalten",
|
||||
"manage_subscription": "Abonnement verwalten",
|
||||
"monthly": "Monatlich",
|
||||
"monthly_identified_users": "Monatlich identifizierte Nutzer",
|
||||
"multi_language_surveys": "Mehrsprachige Umfragen",
|
||||
"per_month": "pro Monat",
|
||||
"per_year": "pro Jahr",
|
||||
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
|
||||
"premium_support_with_slas": "Premium-Support mit SLAs",
|
||||
"priority_support": "Priorisierter Support",
|
||||
"remove_branding": "Branding entfernen",
|
||||
"say_hi": "Sag Hi!",
|
||||
"scale": "Scale",
|
||||
"scale_description": "Erweiterte Funktionen für größere Unternehmen.",
|
||||
"startup": "Start-up",
|
||||
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
|
||||
"switch_plan": "Plan wechseln",
|
||||
"switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.",
|
||||
"team_access_roles": "Rollen für Teammitglieder",
|
||||
"technical_onboarding": "Technische Einführung",
|
||||
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
|
||||
"unlimited_apps_websites": "Unbegrenzte Apps & Websites",
|
||||
"unlimited_miu": "Unbegrenzte MIU",
|
||||
"unlimited_projects": "Unbegrenzte Projekte",
|
||||
"unlimited_responses": "Unbegrenzte Antworten",
|
||||
@@ -1230,8 +1221,9 @@
|
||||
"copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung",
|
||||
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
|
||||
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
|
||||
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
|
||||
"copy_survey_success": "Umfrage erfolgreich kopiert!",
|
||||
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:",
|
||||
"2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:",
|
||||
|
||||
@@ -316,6 +316,7 @@
|
||||
"remove": "Remove",
|
||||
"reorder_and_hide_columns": "Reorder and hide columns",
|
||||
"report_survey": "Report Survey",
|
||||
"request_pricing": "Request Pricing",
|
||||
"request_trial_license": "Request trial license",
|
||||
"reset_to_default": "Reset to default",
|
||||
"response": "Response",
|
||||
@@ -979,63 +980,53 @@
|
||||
"api_keys_description": "Manage API keys to access Formbricks management APIs"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 Monthly Responses",
|
||||
"1500_monthly_responses": "1500 Monthly Responses",
|
||||
"2000_monthly_identified_users": "2000 Monthly Identified Users",
|
||||
"30000_monthly_identified_users": "30000 Monthly Identified Users",
|
||||
"1000_monthly_responses": "Monthly 1,000 Responses",
|
||||
"1_project": "1 Project",
|
||||
"2000_contacts": "2,000 Contacts",
|
||||
"3_projects": "3 Projects",
|
||||
"5000_monthly_responses": "5,000 Monthly Responses",
|
||||
"5_projects": "5 Projects",
|
||||
"7500_monthly_identified_users": "7500 Monthly Identified Users",
|
||||
"advanced_targeting": "Advanced Targeting",
|
||||
"7500_contacts": "7,500 Contacts",
|
||||
"all_integrations": "All Integrations",
|
||||
"all_surveying_features": "All surveying features",
|
||||
"annually": "Annually",
|
||||
"api_webhooks": "API & Webhooks",
|
||||
"app_surveys": "App Surveys",
|
||||
"contact_us": "Contact Us",
|
||||
"attribute_based_targeting": "Attribute-based Targeting",
|
||||
"current": "Current",
|
||||
"current_plan": "Current Plan",
|
||||
"current_tier_limit": "Current Tier Limit",
|
||||
"custom_miu_limit": "Custom MIU limit",
|
||||
"custom": "Custom & Scale",
|
||||
"custom_contacts_limit": "Custom Contacts Limit",
|
||||
"custom_project_limit": "Custom Project Limit",
|
||||
"customer_success_manager": "Customer Success Manager",
|
||||
"custom_response_limit": "Custom Response Limit",
|
||||
"email_embedded_surveys": "Email Embedded Surveys",
|
||||
"email_support": "Email Support",
|
||||
"enterprise": "Enterprise",
|
||||
"email_follow_ups": "Email Follow-ups",
|
||||
"enterprise_description": "Premium support and custom limits.",
|
||||
"everybody_has_the_free_plan_by_default": "Everybody has the free plan by default!",
|
||||
"everything_in_free": "Everything in Free",
|
||||
"everything_in_scale": "Everything in Scale",
|
||||
"everything_in_startup": "Everything in Startup",
|
||||
"free": "Free",
|
||||
"free_description": "Unlimited Surveys, Team Members, and more.",
|
||||
"get_2_months_free": "Get 2 months free",
|
||||
"get_in_touch": "Get in touch",
|
||||
"hosted_in_frankfurt": "Hosted in Frankfurt",
|
||||
"ios_android_sdks": "iOS & Android SDK for mobile surveys",
|
||||
"link_surveys": "Link Surveys (Shareable)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.",
|
||||
"manage_card_details": "Manage Card Details",
|
||||
"manage_subscription": "Manage Subscription",
|
||||
"monthly": "Monthly",
|
||||
"monthly_identified_users": "Monthly Identified Users",
|
||||
"multi_language_surveys": "Multi-Language Surveys",
|
||||
"per_month": "per month",
|
||||
"per_year": "per year",
|
||||
"plan_upgraded_successfully": "Plan upgraded successfully",
|
||||
"premium_support_with_slas": "Premium support with SLAs",
|
||||
"priority_support": "Priority Support",
|
||||
"remove_branding": "Remove Branding",
|
||||
"say_hi": "Say Hi!",
|
||||
"scale": "Scale",
|
||||
"scale_description": "Advanced features for scaling your business.",
|
||||
"startup": "Startup",
|
||||
"startup_description": "Everything in Free with additional features.",
|
||||
"switch_plan": "Switch Plan",
|
||||
"switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.",
|
||||
"team_access_roles": "Team Access Roles",
|
||||
"technical_onboarding": "Technical Onboarding",
|
||||
"unable_to_upgrade_plan": "Unable to upgrade plan",
|
||||
"unlimited_apps_websites": "Unlimited Apps & Websites",
|
||||
"unlimited_miu": "Unlimited MIU",
|
||||
"unlimited_projects": "Unlimited Projects",
|
||||
"unlimited_responses": "Unlimited Responses",
|
||||
@@ -1230,8 +1221,9 @@
|
||||
"copy_survey_description": "Copy this survey to another environment",
|
||||
"copy_survey_error": "Failed to copy survey",
|
||||
"copy_survey_link_to_clipboard": "Copy survey link to clipboard",
|
||||
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
|
||||
"copy_survey_success": "Survey copied successfully!",
|
||||
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses? This action cannot be undone.",
|
||||
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:",
|
||||
"2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:",
|
||||
|
||||
@@ -316,6 +316,7 @@
|
||||
"remove": "Retirer",
|
||||
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
|
||||
"report_survey": "Rapport d'enquête",
|
||||
"request_pricing": "Demander la tarification",
|
||||
"request_trial_license": "Demander une licence d'essai",
|
||||
"reset_to_default": "Réinitialiser par défaut",
|
||||
"response": "Réponse",
|
||||
@@ -979,63 +980,53 @@
|
||||
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 Réponses Mensuelles",
|
||||
"1500_monthly_responses": "1500 Réponses Mensuelles",
|
||||
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels",
|
||||
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
|
||||
"1000_monthly_responses": "1000 Réponses Mensuelles",
|
||||
"1_project": "1 Projet",
|
||||
"2000_contacts": "2 000 Contacts",
|
||||
"3_projects": "3 Projets",
|
||||
"5000_monthly_responses": "5,000 Réponses Mensuelles",
|
||||
"5_projects": "5 Projets",
|
||||
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
|
||||
"advanced_targeting": "Ciblage Avancé",
|
||||
"7500_contacts": "7 500 Contacts",
|
||||
"all_integrations": "Toutes les intégrations",
|
||||
"all_surveying_features": "Tous les outils d'arpentage",
|
||||
"annually": "Annuellement",
|
||||
"api_webhooks": "API et Webhooks",
|
||||
"app_surveys": "Sondages d'application",
|
||||
"contact_us": "Contactez-nous",
|
||||
"attribute_based_targeting": "Ciblage basé sur les attributs",
|
||||
"current": "Actuel",
|
||||
"current_plan": "Plan actuel",
|
||||
"current_tier_limit": "Limite de niveau actuel",
|
||||
"custom_miu_limit": "Limite MIU personnalisé",
|
||||
"custom": "Personnalisé et Échelle",
|
||||
"custom_contacts_limit": "Limite de contacts personnalisé",
|
||||
"custom_project_limit": "Limite de projet personnalisé",
|
||||
"customer_success_manager": "Responsable de la réussite client",
|
||||
"custom_response_limit": "Limite de réponse personnalisé",
|
||||
"email_embedded_surveys": "Sondages intégrés par e-mail",
|
||||
"email_support": "Support par e-mail",
|
||||
"enterprise": "Entreprise",
|
||||
"email_follow_ups": "Relances par e-mail",
|
||||
"enterprise_description": "Soutien premium et limites personnalisées.",
|
||||
"everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !",
|
||||
"everything_in_free": "Tout est gratuit",
|
||||
"everything_in_scale": "Tout à l'échelle",
|
||||
"everything_in_startup": "Tout dans le Startup",
|
||||
"free": "Gratuit",
|
||||
"free_description": "Sondages illimités, membres d'équipe, et plus encore.",
|
||||
"get_2_months_free": "Obtenez 2 mois gratuits",
|
||||
"get_in_touch": "Prenez contact",
|
||||
"hosted_in_frankfurt": "Hébergé à Francfort",
|
||||
"ios_android_sdks": "SDK iOS et Android pour les sondages mobiles",
|
||||
"link_surveys": "Sondages par lien (partageables)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.",
|
||||
"manage_card_details": "Gérer les détails de la carte",
|
||||
"manage_subscription": "Gérer l'abonnement",
|
||||
"monthly": "Mensuel",
|
||||
"monthly_identified_users": "Utilisateurs Identifiés Mensuels",
|
||||
"multi_language_surveys": "Sondages multilingues",
|
||||
"per_month": "par mois",
|
||||
"per_year": "par an",
|
||||
"plan_upgraded_successfully": "Plan mis à jour avec succès",
|
||||
"premium_support_with_slas": "Soutien premium avec SLA",
|
||||
"priority_support": "Soutien Prioritaire",
|
||||
"remove_branding": "Supprimer la marque",
|
||||
"say_hi": "Dis bonjour !",
|
||||
"scale": "Échelle",
|
||||
"scale_description": "Fonctionnalités avancées pour développer votre entreprise.",
|
||||
"startup": "Startup",
|
||||
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
|
||||
"switch_plan": "Changer de plan",
|
||||
"switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.",
|
||||
"team_access_roles": "Rôles d'accès d'équipe",
|
||||
"technical_onboarding": "Intégration technique",
|
||||
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
|
||||
"unlimited_apps_websites": "Applications et sites Web illimités",
|
||||
"unlimited_miu": "MIU Illimité",
|
||||
"unlimited_projects": "Projets illimités",
|
||||
"unlimited_responses": "Réponses illimitées",
|
||||
@@ -1230,8 +1221,9 @@
|
||||
"copy_survey_description": "Copier cette enquête dans un autre environnement",
|
||||
"copy_survey_error": "Échec de la copie du sondage",
|
||||
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
|
||||
"copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
|
||||
"copy_survey_success": "Enquête copiée avec succès !",
|
||||
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses ? Cette action ne peut pas être annulée.",
|
||||
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
|
||||
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
|
||||
|
||||
@@ -316,6 +316,7 @@
|
||||
"remove": "remover",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
"report_survey": "Relatório de Pesquisa",
|
||||
"request_pricing": "Solicitar Preços",
|
||||
"request_trial_license": "Pedir licença de teste",
|
||||
"reset_to_default": "Restaurar para o padrão",
|
||||
"response": "Resposta",
|
||||
@@ -979,63 +980,53 @@
|
||||
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 Respostas Mensais",
|
||||
"1500_monthly_responses": "1500 Respostas Mensais",
|
||||
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente",
|
||||
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
|
||||
"1000_monthly_responses": "1000 Respostas Mensais",
|
||||
"1_project": "1 Projeto",
|
||||
"2000_contacts": "2.000 Contatos",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"5_projects": "5 Projetos",
|
||||
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
|
||||
"advanced_targeting": "Mira Avançada",
|
||||
"7500_contacts": "7.500 Contatos",
|
||||
"all_integrations": "Todas as Integrações",
|
||||
"all_surveying_features": "Todos os recursos de levantamento",
|
||||
"annually": "anualmente",
|
||||
"api_webhooks": "API e Webhooks",
|
||||
"app_surveys": "Pesquisas de App",
|
||||
"contact_us": "Fale Conosco",
|
||||
"attribute_based_targeting": "Segmentação Baseada em Atributos",
|
||||
"current": "atual",
|
||||
"current_plan": "Plano Atual",
|
||||
"current_tier_limit": "Limite Atual de Nível",
|
||||
"custom_miu_limit": "Limite MIU personalizado",
|
||||
"custom": "Personalizado e Escala",
|
||||
"custom_contacts_limit": "Limite de Contatos Personalizado",
|
||||
"custom_project_limit": "Limite de Projeto Personalizado",
|
||||
"customer_success_manager": "Gerente de Sucesso do Cliente",
|
||||
"custom_response_limit": "Limite de Resposta Personalizado",
|
||||
"email_embedded_surveys": "Pesquisas Incorporadas no Email",
|
||||
"email_support": "Suporte por Email",
|
||||
"enterprise": "Empresa",
|
||||
"email_follow_ups": "Acompanhamentos por Email",
|
||||
"enterprise_description": "Suporte premium e limites personalizados.",
|
||||
"everybody_has_the_free_plan_by_default": "Todo mundo tem o plano gratuito por padrão!",
|
||||
"everything_in_free": "Tudo de graça",
|
||||
"everything_in_scale": "Tudo em Escala",
|
||||
"everything_in_startup": "Tudo em Startup",
|
||||
"free": "grátis",
|
||||
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
|
||||
"get_2_months_free": "Ganhe 2 meses grátis",
|
||||
"get_in_touch": "Entre em contato",
|
||||
"hosted_in_frankfurt": "Hospedado em Frankfurt",
|
||||
"ios_android_sdks": "SDK para iOS e Android para pesquisas móveis",
|
||||
"link_surveys": "Link de Pesquisas (Compartilhável)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.",
|
||||
"manage_card_details": "Gerenciar Detalhes do Cartão",
|
||||
"manage_subscription": "Gerenciar Assinatura",
|
||||
"monthly": "mensal",
|
||||
"monthly_identified_users": "Usuários Identificados Mensalmente",
|
||||
"multi_language_surveys": "Pesquisas Multilíngues",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_upgraded_successfully": "Plano atualizado com sucesso",
|
||||
"premium_support_with_slas": "Suporte premium com SLAs",
|
||||
"priority_support": "Suporte Prioritário",
|
||||
"remove_branding": "Remover Marca",
|
||||
"say_hi": "Diz oi!",
|
||||
"scale": "escala",
|
||||
"scale_description": "Recursos avançados pra escalar seu negócio.",
|
||||
"startup": "startup",
|
||||
"startup_description": "Tudo no Grátis com recursos adicionais.",
|
||||
"switch_plan": "Mudar Plano",
|
||||
"switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.",
|
||||
"team_access_roles": "Funções de Acesso da Equipe",
|
||||
"technical_onboarding": "Integração Técnica",
|
||||
"unable_to_upgrade_plan": "Não foi possível atualizar o plano",
|
||||
"unlimited_apps_websites": "Apps e Sites Ilimitados",
|
||||
"unlimited_miu": "MIU Ilimitado",
|
||||
"unlimited_projects": "Projetos Ilimitados",
|
||||
"unlimited_responses": "Respostas Ilimitadas",
|
||||
@@ -1230,8 +1221,9 @@
|
||||
"copy_survey_description": "Copiar essa pesquisa para outro ambiente",
|
||||
"copy_survey_error": "Falha ao copiar pesquisa",
|
||||
"copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência",
|
||||
"copy_survey_partially_success": "{success} pesquisas copiadas com sucesso, {error} falharam.",
|
||||
"copy_survey_success": "Pesquisa copiada com sucesso!",
|
||||
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas? Essa ação não pode ser desfeita.",
|
||||
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:",
|
||||
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
|
||||
|
||||
@@ -316,6 +316,7 @@
|
||||
"remove": "Remover",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
"report_survey": "Relatório de Inquérito",
|
||||
"request_pricing": "Pedido de Preços",
|
||||
"request_trial_license": "Solicitar licença de teste",
|
||||
"reset_to_default": "Repor para o padrão",
|
||||
"response": "Resposta",
|
||||
@@ -979,63 +980,53 @@
|
||||
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 Respostas Mensais",
|
||||
"1500_monthly_responses": "1500 Respostas Mensais",
|
||||
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
|
||||
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
|
||||
"1000_monthly_responses": "1000 Respostas Mensais",
|
||||
"1_project": "1 Projeto",
|
||||
"2000_contacts": "2,000 Contactos",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"5_projects": "5 Projetos",
|
||||
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
|
||||
"advanced_targeting": "Segmentação Avançada",
|
||||
"7500_contacts": "7,500 Contactos",
|
||||
"all_integrations": "Todas as Integrações",
|
||||
"all_surveying_features": "Todas as funcionalidades de inquérito",
|
||||
"annually": "Anualmente",
|
||||
"api_webhooks": "API e Webhooks",
|
||||
"app_surveys": "Inquéritos da Aplicação",
|
||||
"contact_us": "Contacte-nos",
|
||||
"attribute_based_targeting": "Segmentação Baseada em Atributos",
|
||||
"current": "Atual",
|
||||
"current_plan": "Plano Atual",
|
||||
"current_tier_limit": "Limite Atual do Nível",
|
||||
"custom_miu_limit": "Limite MIU Personalizado",
|
||||
"custom": "Personalizado e Escala",
|
||||
"custom_contacts_limit": "Limite de Contactos Personalizado",
|
||||
"custom_project_limit": "Limite de Projeto Personalizado",
|
||||
"customer_success_manager": "Gestor de Sucesso do Cliente",
|
||||
"custom_response_limit": "Limite de Resposta Personalizado",
|
||||
"email_embedded_surveys": "Inquéritos Incorporados no Email",
|
||||
"email_support": "Suporte por Email",
|
||||
"enterprise": "Empresa",
|
||||
"email_follow_ups": "Acompanhamentos por Email",
|
||||
"enterprise_description": "Suporte premium e limites personalizados.",
|
||||
"everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!",
|
||||
"everything_in_free": "Tudo em Gratuito",
|
||||
"everything_in_scale": "Tudo em Escala",
|
||||
"everything_in_startup": "Tudo em Startup",
|
||||
"free": "Grátis",
|
||||
"free_description": "Inquéritos ilimitados, membros da equipa e mais.",
|
||||
"get_2_months_free": "Obtenha 2 meses grátis",
|
||||
"get_in_touch": "Entre em contacto",
|
||||
"hosted_in_frankfurt": "Hospedado em Frankfurt",
|
||||
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
|
||||
"link_surveys": "Ligar Inquéritos (Partilhável)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.",
|
||||
"manage_card_details": "Gerir Detalhes do Cartão",
|
||||
"manage_subscription": "Gerir Subscrição",
|
||||
"monthly": "Mensal",
|
||||
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
|
||||
"multi_language_surveys": "Inquéritos Multilingues",
|
||||
"per_month": "por mês",
|
||||
"per_year": "por ano",
|
||||
"plan_upgraded_successfully": "Plano atualizado com sucesso",
|
||||
"premium_support_with_slas": "Suporte premium com SLAs",
|
||||
"priority_support": "Suporte Prioritário",
|
||||
"remove_branding": "Remover Marca",
|
||||
"say_hi": "Diga Olá!",
|
||||
"scale": "Escala",
|
||||
"scale_description": "Funcionalidades avançadas para escalar o seu negócio.",
|
||||
"startup": "Inicialização",
|
||||
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
|
||||
"switch_plan": "Mudar Plano",
|
||||
"switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.",
|
||||
"team_access_roles": "Funções de Acesso da Equipa",
|
||||
"technical_onboarding": "Integração Técnica",
|
||||
"unable_to_upgrade_plan": "Não é possível atualizar o plano",
|
||||
"unlimited_apps_websites": "Aplicações e Websites Ilimitados",
|
||||
"unlimited_miu": "MIU Ilimitado",
|
||||
"unlimited_projects": "Projetos Ilimitados",
|
||||
"unlimited_responses": "Respostas Ilimitadas",
|
||||
@@ -1230,8 +1221,9 @@
|
||||
"copy_survey_description": "Copiar este questionário para outro ambiente",
|
||||
"copy_survey_error": "Falha ao copiar inquérito",
|
||||
"copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência",
|
||||
"copy_survey_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.",
|
||||
"copy_survey_success": "Inquérito copiado com sucesso!",
|
||||
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas? Esta ação não pode ser desfeita.",
|
||||
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:",
|
||||
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
|
||||
|
||||
@@ -316,6 +316,7 @@
|
||||
"remove": "移除",
|
||||
"reorder_and_hide_columns": "重新排序和隱藏欄位",
|
||||
"report_survey": "報告問卷",
|
||||
"request_pricing": "請求定價",
|
||||
"request_trial_license": "請求試用授權",
|
||||
"reset_to_default": "重設為預設值",
|
||||
"response": "回應",
|
||||
@@ -979,63 +980,53 @@
|
||||
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
|
||||
},
|
||||
"billing": {
|
||||
"10000_monthly_responses": "10000 個每月回應",
|
||||
"1500_monthly_responses": "1500 個每月回應",
|
||||
"2000_monthly_identified_users": "2000 個每月識別使用者",
|
||||
"30000_monthly_identified_users": "30000 個每月識別使用者",
|
||||
"1000_monthly_responses": "1000 個每月回應",
|
||||
"1_project": "1 個專案",
|
||||
"2000_contacts": "2000 個聯絡人",
|
||||
"3_projects": "3 個專案",
|
||||
"5000_monthly_responses": "5000 個每月回應",
|
||||
"5_projects": "5 個專案",
|
||||
"7500_monthly_identified_users": "7500 個每月識別使用者",
|
||||
"advanced_targeting": "進階目標設定",
|
||||
"7500_contacts": "7500 個聯絡人",
|
||||
"all_integrations": "所有整合",
|
||||
"all_surveying_features": "所有調查功能",
|
||||
"annually": "每年",
|
||||
"api_webhooks": "API 和 Webhook",
|
||||
"app_surveys": "應用程式問卷",
|
||||
"contact_us": "聯絡我們",
|
||||
"attribute_based_targeting": "基於屬性的定位",
|
||||
"current": "目前",
|
||||
"current_plan": "目前方案",
|
||||
"current_tier_limit": "目前層級限制",
|
||||
"custom_miu_limit": "自訂 MIU 上限",
|
||||
"custom": "自訂 & 規模",
|
||||
"custom_contacts_limit": "自訂聯絡人上限",
|
||||
"custom_project_limit": "自訂專案上限",
|
||||
"customer_success_manager": "客戶成功經理",
|
||||
"custom_response_limit": "自訂回應上限",
|
||||
"email_embedded_surveys": "電子郵件嵌入式問卷",
|
||||
"email_support": "電子郵件支援",
|
||||
"enterprise": "企業版",
|
||||
"email_follow_ups": "電子郵件後續追蹤",
|
||||
"enterprise_description": "頂級支援和自訂限制。",
|
||||
"everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!",
|
||||
"everything_in_free": "免費方案中的所有功能",
|
||||
"everything_in_scale": "進階方案中的所有功能",
|
||||
"everything_in_startup": "啟動方案中的所有功能",
|
||||
"free": "免費",
|
||||
"free_description": "無限問卷、團隊成員等。",
|
||||
"get_2_months_free": "免費獲得 2 個月",
|
||||
"get_in_touch": "取得聯繫",
|
||||
"hosted_in_frankfurt": "託管在 Frankfurt",
|
||||
"ios_android_sdks": "iOS 和 Android SDK 用於行動問卷",
|
||||
"link_surveys": "連結問卷(可分享)",
|
||||
"logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。",
|
||||
"manage_card_details": "管理卡片詳細資料",
|
||||
"manage_subscription": "管理訂閱",
|
||||
"monthly": "每月",
|
||||
"monthly_identified_users": "每月識別使用者",
|
||||
"multi_language_surveys": "多語言問卷",
|
||||
"per_month": "每月",
|
||||
"per_year": "每年",
|
||||
"plan_upgraded_successfully": "方案已成功升級",
|
||||
"premium_support_with_slas": "具有 SLA 的頂級支援",
|
||||
"priority_support": "優先支援",
|
||||
"remove_branding": "移除品牌",
|
||||
"say_hi": "打個招呼!",
|
||||
"scale": "進階版",
|
||||
"scale_description": "用於擴展業務的進階功能。",
|
||||
"startup": "啟動版",
|
||||
"startup_description": "免費方案中的所有功能以及其他功能。",
|
||||
"switch_plan": "切換方案",
|
||||
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
|
||||
"team_access_roles": "團隊存取角色",
|
||||
"technical_onboarding": "技術新手上路",
|
||||
"unable_to_upgrade_plan": "無法升級方案",
|
||||
"unlimited_apps_websites": "無限應用程式和網站",
|
||||
"unlimited_miu": "無限 MIU",
|
||||
"unlimited_projects": "無限專案",
|
||||
"unlimited_responses": "無限回應",
|
||||
@@ -1230,8 +1221,9 @@
|
||||
"copy_survey_description": "將此問卷複製到另一個環境",
|
||||
"copy_survey_error": "無法複製問卷",
|
||||
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
|
||||
"copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。",
|
||||
"copy_survey_success": "問卷已成功複製!",
|
||||
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?此操作無法復原。",
|
||||
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:",
|
||||
"2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
@@ -100,8 +99,6 @@ describe("DeleteAccountModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
const input = screen.getByTestId("deleteAccountConfirmation");
|
||||
fireEvent.change(input, { target: { value: mockUser.email } });
|
||||
|
||||
@@ -113,8 +110,8 @@ describe("DeleteAccountModal", () => {
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Updated to match new implementation
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(window.location.replace).toHaveBeenCalledWith("/auth/login");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
@@ -151,15 +148,13 @@ describe("DeleteAccountModal", () => {
|
||||
const form = screen.getByTestId("deleteAccountForm");
|
||||
fireEvent.submit(form);
|
||||
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteUserAction).toHaveBeenCalled();
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Updated to match new implementation
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(window.location.replace).toHaveBeenCalledWith(
|
||||
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2"
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
@@ -39,12 +38,11 @@ export const DeleteAccountModal = ({
|
||||
setDeleting(true);
|
||||
await deleteUserAction();
|
||||
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
// Sign out with account deletion reason (no automatic redirect)
|
||||
await signOutWithAudit({
|
||||
reason: "account_deletion",
|
||||
redirect: false, // Prevent NextAuth automatic redirect
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
|
||||
// Manual redirect after signOut completes
|
||||
|
||||
@@ -21,9 +21,9 @@ export const forgotPasswordAction = actionClient
|
||||
|
||||
const user = await getUserByEmail(parsedInput.email);
|
||||
|
||||
if (!user || user.identityProvider !== "email") {
|
||||
return;
|
||||
if (user && user.identityProvider === "email") {
|
||||
await sendForgotPasswordEmail(user);
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(user);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { logSignOutAction } from "@/modules/auth/actions/sign-out";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -14,6 +15,7 @@ interface UseSignOutOptions {
|
||||
organizationId?: string;
|
||||
redirect?: boolean;
|
||||
callbackUrl?: string;
|
||||
clearEnvironmentId?: boolean;
|
||||
}
|
||||
|
||||
interface SessionUser {
|
||||
@@ -42,6 +44,10 @@ export const useSignOut = (sessionUser?: SessionUser | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.clearEnvironmentId) {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
}
|
||||
|
||||
// Call NextAuth signOut
|
||||
return await signOut({
|
||||
redirect: options?.redirect,
|
||||
|
||||
@@ -50,6 +50,7 @@ export const ZAuditAction = z.enum([
|
||||
"twoFactorRequired",
|
||||
"emailVerificationAttempted",
|
||||
"userSignedOut",
|
||||
"passwordReset",
|
||||
]);
|
||||
export const ZActor = z.enum(["user", "api", "system"]);
|
||||
export const ZAuditStatus = z.enum(["success", "failure"]);
|
||||
|
||||
@@ -1,87 +1,87 @@
|
||||
import { TFnType } from "@tolgee/react";
|
||||
|
||||
export const getCloudPricingData = (t: TFnType) => {
|
||||
return {
|
||||
plans: [
|
||||
{
|
||||
name: t("environments.settings.billing.free"),
|
||||
id: "free",
|
||||
featured: false,
|
||||
description: t("environments.settings.billing.free_description"),
|
||||
price: { monthly: "$0", yearly: "$0" },
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.unlimited_surveys"),
|
||||
t("environments.settings.billing.unlimited_team_members"),
|
||||
t("environments.settings.billing.3_projects"),
|
||||
t("environments.settings.billing.1500_monthly_responses"),
|
||||
t("environments.settings.billing.2000_monthly_identified_users"),
|
||||
t("environments.settings.billing.website_surveys"),
|
||||
t("environments.settings.billing.app_surveys"),
|
||||
t("environments.settings.billing.unlimited_apps_websites"),
|
||||
t("environments.settings.billing.link_surveys"),
|
||||
t("environments.settings.billing.email_embedded_surveys"),
|
||||
t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"),
|
||||
t("environments.settings.billing.api_webhooks"),
|
||||
t("environments.settings.billing.all_integrations"),
|
||||
t("environments.settings.billing.all_surveying_features"),
|
||||
],
|
||||
href: "https://app.formbricks.com/auth/signup?plan=free",
|
||||
},
|
||||
{
|
||||
name: t("environments.settings.billing.startup"),
|
||||
id: "startup",
|
||||
featured: false,
|
||||
description: t("environments.settings.billing.startup_description"),
|
||||
price: { monthly: "$39", yearly: "$390 " },
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.everything_in_free"),
|
||||
t("environments.settings.billing.unlimited_surveys"),
|
||||
t("environments.settings.billing.remove_branding"),
|
||||
t("environments.settings.billing.email_support"),
|
||||
t("environments.settings.billing.3_projects"),
|
||||
t("environments.settings.billing.5000_monthly_responses"),
|
||||
t("environments.settings.billing.7500_monthly_identified_users"),
|
||||
],
|
||||
href: "https://app.formbricks.com/auth/signup?plan=startup",
|
||||
},
|
||||
{
|
||||
name: t("environments.settings.billing.scale"),
|
||||
id: "scale",
|
||||
featured: true,
|
||||
description: t("environments.settings.billing.scale_description"),
|
||||
price: { monthly: "$149", yearly: "$1,490" },
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.everything_in_startup"),
|
||||
t("environments.settings.billing.team_access_roles"),
|
||||
t("environments.settings.billing.multi_language_surveys"),
|
||||
t("environments.settings.billing.advanced_targeting"),
|
||||
t("environments.settings.billing.priority_support"),
|
||||
t("environments.settings.billing.5_projects"),
|
||||
t("environments.settings.billing.10000_monthly_responses"),
|
||||
t("environments.settings.billing.30000_monthly_identified_users"),
|
||||
],
|
||||
href: "https://app.formbricks.com/auth/signup?plan=scale",
|
||||
},
|
||||
{
|
||||
name: t("environments.settings.billing.enterprise"),
|
||||
id: "enterprise",
|
||||
featured: false,
|
||||
description: t("environments.settings.billing.enterprise_description"),
|
||||
price: {
|
||||
monthly: t("environments.settings.billing.say_hi"),
|
||||
yearly: t("environments.settings.billing.say_hi"),
|
||||
},
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.everything_in_scale"),
|
||||
t("environments.settings.billing.custom_project_limit"),
|
||||
t("environments.settings.billing.custom_miu_limit"),
|
||||
t("environments.settings.billing.premium_support_with_slas"),
|
||||
t("environments.settings.billing.uptime_sla_99"),
|
||||
t("environments.settings.billing.customer_success_manager"),
|
||||
t("environments.settings.billing.technical_onboarding"),
|
||||
],
|
||||
href: "https://cal.com/johannes/enterprise-cloud",
|
||||
},
|
||||
export type TPricingPlan = {
|
||||
id: string;
|
||||
name: string;
|
||||
featured: boolean;
|
||||
CTA?: string;
|
||||
description: string;
|
||||
price: {
|
||||
monthly: string;
|
||||
yearly: string;
|
||||
};
|
||||
mainFeatures: string[];
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export const getCloudPricingData = (t: TFnType): { plans: TPricingPlan[] } => {
|
||||
const freePlan: TPricingPlan = {
|
||||
id: "free",
|
||||
name: t("environments.settings.billing.free"),
|
||||
featured: false,
|
||||
description: t("environments.settings.billing.free_description"),
|
||||
price: { monthly: "$0", yearly: "$0" },
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.unlimited_surveys"),
|
||||
t("environments.settings.billing.1000_monthly_responses"),
|
||||
t("environments.settings.billing.2000_contacts"),
|
||||
t("environments.settings.billing.1_project"),
|
||||
t("environments.settings.billing.unlimited_team_members"),
|
||||
t("environments.settings.billing.link_surveys"),
|
||||
t("environments.settings.billing.website_surveys"),
|
||||
t("environments.settings.billing.app_surveys"),
|
||||
t("environments.settings.billing.ios_android_sdks"),
|
||||
t("environments.settings.billing.email_embedded_surveys"),
|
||||
t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"),
|
||||
t("environments.settings.billing.api_webhooks"),
|
||||
t("environments.settings.billing.all_integrations"),
|
||||
t("environments.settings.billing.hosted_in_frankfurt") + " 🇪🇺",
|
||||
],
|
||||
};
|
||||
|
||||
const startupPlan: TPricingPlan = {
|
||||
id: "startup",
|
||||
name: t("environments.settings.billing.startup"),
|
||||
featured: true,
|
||||
CTA: t("common.start_free_trial"),
|
||||
description: t("environments.settings.billing.startup_description"),
|
||||
price: { monthly: "$49", yearly: "$490" },
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.everything_in_free"),
|
||||
t("environments.settings.billing.5000_monthly_responses"),
|
||||
t("environments.settings.billing.7500_contacts"),
|
||||
t("environments.settings.billing.3_projects"),
|
||||
t("environments.settings.billing.remove_branding"),
|
||||
t("environments.settings.billing.email_follow_ups"),
|
||||
t("environments.settings.billing.attribute_based_targeting"),
|
||||
],
|
||||
};
|
||||
|
||||
const customPlan: TPricingPlan = {
|
||||
id: "enterprise",
|
||||
name: t("environments.settings.billing.custom"),
|
||||
featured: false,
|
||||
CTA: t("common.request_pricing"),
|
||||
description: t("environments.settings.billing.enterprise_description"),
|
||||
price: {
|
||||
monthly: t("environments.settings.billing.custom"),
|
||||
yearly: t("environments.settings.billing.custom"),
|
||||
},
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.everything_in_startup"),
|
||||
t("environments.settings.billing.custom_response_limit"),
|
||||
t("environments.settings.billing.custom_contacts_limit"),
|
||||
t("environments.settings.billing.custom_project_limit"),
|
||||
t("environments.settings.billing.team_access_roles"),
|
||||
t("environments.project.languages.multi_language_surveys"),
|
||||
t("environments.settings.enterprise.saml_sso"),
|
||||
t("environments.settings.billing.uptime_sla_99"),
|
||||
t("environments.settings.billing.premium_support_with_slas"),
|
||||
],
|
||||
href: "https://app.formbricks.com/s/cm7k8esy20001jp030fh8a9o5?source=billingView&delivery=cloud",
|
||||
};
|
||||
|
||||
return {
|
||||
plans: [freePlan, startupPlan, customPlan],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,19 +8,10 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { TPricingPlan } from "../api/lib/constants";
|
||||
|
||||
interface PricingCardProps {
|
||||
plan: {
|
||||
id: string;
|
||||
name: string;
|
||||
featured: boolean;
|
||||
price: {
|
||||
monthly: string;
|
||||
yearly: string;
|
||||
};
|
||||
mainFeatures: string[];
|
||||
href: string;
|
||||
};
|
||||
plan: TPricingPlan;
|
||||
planPeriod: TOrganizationBillingPeriod;
|
||||
organization: TOrganization;
|
||||
onUpgrade: () => Promise<void>;
|
||||
@@ -28,7 +19,6 @@ interface PricingCardProps {
|
||||
projectFeatureKeys: {
|
||||
FREE: string;
|
||||
STARTUP: string;
|
||||
SCALE: string;
|
||||
ENTERPRISE: string;
|
||||
};
|
||||
}
|
||||
@@ -72,18 +62,33 @@ export const PricingCard = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (plan.id !== projectFeatureKeys.ENTERPRISE && plan.id !== projectFeatureKeys.FREE) {
|
||||
if (plan.id === projectFeatureKeys.ENTERPRISE) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
window.open(plan.href, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
className="flex justify-center bg-white">
|
||||
{t(plan.CTA ?? "common.request_pricing")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (plan.id === projectFeatureKeys.STARTUP) {
|
||||
if (organization.billing.plan === projectFeatureKeys.FREE) {
|
||||
return (
|
||||
<Button
|
||||
loading={loading}
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onUpgrade();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center">
|
||||
{t("common.start_free_trial")}
|
||||
{t(plan.CTA ?? "common.start_free_trial")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -100,15 +105,20 @@ export const PricingCard = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
return null;
|
||||
}, [
|
||||
isCurrentPlan,
|
||||
loading,
|
||||
onUpgrade,
|
||||
organization.billing.plan,
|
||||
plan.CTA,
|
||||
plan.featured,
|
||||
plan.href,
|
||||
plan.id,
|
||||
projectFeatureKeys.ENTERPRISE,
|
||||
projectFeatureKeys.FREE,
|
||||
projectFeatureKeys.STARTUP,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -147,7 +157,7 @@ export const PricingCard = ({
|
||||
: plan.price.yearly
|
||||
: t(plan.price.monthly)}
|
||||
</p>
|
||||
{plan.name !== "Enterprise" && (
|
||||
{plan.id !== projectFeatureKeys.ENTERPRISE && (
|
||||
<div className="text-sm leading-5">
|
||||
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
|
||||
/ {planPeriod === "monthly" ? "Month" : "Year"}
|
||||
@@ -171,16 +181,9 @@ export const PricingCard = ({
|
||||
{t("environments.settings.billing.manage_subscription")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{organization.billing.plan !== plan.id && plan.id === projectFeatureKeys.ENTERPRISE && (
|
||||
<Button loading={loading} onClick={() => onUpgrade()} className="flex justify-center">
|
||||
{t("environments.settings.billing.contact_us")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 flow-root sm:mt-10">
|
||||
<ul
|
||||
role="list"
|
||||
className={cn(
|
||||
plan.featured
|
||||
? "divide-slate-900/5 border-slate-900/5 text-slate-600"
|
||||
@@ -193,7 +196,6 @@ export const PricingCard = ({
|
||||
className={cn(plan.featured ? "text-brand-dark" : "text-slate-500", "h-6 w-5 flex-none")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{t(mainFeature)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -21,15 +21,12 @@ interface PricingTableProps {
|
||||
responseCount: number;
|
||||
projectCount: number;
|
||||
stripePriceLookupKeys: {
|
||||
STARTUP_MONTHLY: string;
|
||||
STARTUP_YEARLY: string;
|
||||
SCALE_MONTHLY: string;
|
||||
SCALE_YEARLY: string;
|
||||
STARTUP_MAY25_MONTHLY: string;
|
||||
STARTUP_MAY25_YEARLY: string;
|
||||
};
|
||||
projectFeatureKeys: {
|
||||
FREE: string;
|
||||
STARTUP: string;
|
||||
SCALE: string;
|
||||
ENTERPRISE: string;
|
||||
};
|
||||
hasBillingRights: boolean;
|
||||
@@ -102,35 +99,32 @@ export const PricingTable = ({
|
||||
throw new Error(t("common.something_went_wrong_please_try_again"));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
|
||||
if (err instanceof Error) {
|
||||
toast.error(err.message);
|
||||
} else {
|
||||
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onUpgrade = async (planId: string) => {
|
||||
if (planId === "scale") {
|
||||
await upgradePlan(
|
||||
planPeriod === "monthly" ? stripePriceLookupKeys.SCALE_MONTHLY : stripePriceLookupKeys.SCALE_YEARLY
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (planId === "startup") {
|
||||
await upgradePlan(
|
||||
planPeriod === "monthly"
|
||||
? stripePriceLookupKeys.STARTUP_MONTHLY
|
||||
: stripePriceLookupKeys.STARTUP_YEARLY
|
||||
? stripePriceLookupKeys.STARTUP_MAY25_MONTHLY
|
||||
: stripePriceLookupKeys.STARTUP_MAY25_YEARLY
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (planId === "enterprise") {
|
||||
window.location.href = "https://cal.com/johannes/license";
|
||||
if (planId === "custom") {
|
||||
window.location.href =
|
||||
"https://app.formbricks.com/s/cm7k8esy20001jp030fh8a9o5?source=billingView&delivery=cloud";
|
||||
return;
|
||||
}
|
||||
|
||||
if (planId === "free") {
|
||||
toast.error(t("environments.settings.billing.everybody_has_the_free_plan_by_default"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -233,7 +227,7 @@ export const PricingTable = ({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-8 flex flex-col gap-4 pb-12",
|
||||
"relative mx-8 flex flex-col gap-4 pb-6",
|
||||
projectsUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
|
||||
)}>
|
||||
<p className="text-md font-semibold text-slate-700">{t("common.projects")}</p>
|
||||
@@ -282,7 +276,7 @@ export const PricingTable = ({
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-4">
|
||||
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-3">
|
||||
<div
|
||||
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||
import { TUserProject } from "@/modules/survey/list/types/projects";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { CopySurveyForm } from "./copy-survey-form";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
copySurveyToOtherEnvironmentAction: vi.fn().mockResolvedValue({}),
|
||||
copySurveyToOtherEnvironmentAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
@@ -19,21 +20,40 @@ vi.mock("react-hot-toast", () => ({
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
t: (key: string, params?: any) => {
|
||||
if (key === "environments.surveys.copy_survey_partially_success") {
|
||||
return `Partially successful: ${params?.success} success, ${params?.error} error`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the Checkbox component to properly handle form changes
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn((result) => {
|
||||
if (result?.serverError) return result.serverError;
|
||||
if (result?.validationErrors) return "Validation error";
|
||||
return "Unknown error";
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the form components to make them testable
|
||||
vi.mock("@/modules/ui/components/form", () => ({
|
||||
FormProvider: ({ children }: any) => <div data-testid="form-provider">{children}</div>,
|
||||
FormField: ({ children, render }: any) => (
|
||||
<div data-testid="form-field">{render({ field: { value: [], onChange: vi.fn() } })}</div>
|
||||
),
|
||||
FormItem: ({ children }: any) => <div data-testid="form-item">{children}</div>,
|
||||
FormControl: ({ children }: any) => <div data-testid="form-control">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/checkbox", () => ({
|
||||
Checkbox: ({ id, onCheckedChange, ...props }: any) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
data-testid={id}
|
||||
name={props.name}
|
||||
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
onChange={(e) => {
|
||||
// Call onCheckedChange with the checked state
|
||||
onCheckedChange && onCheckedChange(e.target.checked);
|
||||
}}
|
||||
{...props}
|
||||
@@ -54,10 +74,47 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children, htmlFor }: any) => <label htmlFor={htmlFor}>{children}</label>,
|
||||
}));
|
||||
|
||||
// Create a mock submit handler
|
||||
let mockSubmitHandler: any = null;
|
||||
|
||||
// Mock react-hook-form
|
||||
vi.mock("react-hook-form", () => ({
|
||||
useForm: () => ({
|
||||
control: {},
|
||||
handleSubmit: (fn: any) => {
|
||||
mockSubmitHandler = fn;
|
||||
return (e: any) => {
|
||||
e.preventDefault();
|
||||
// Simulate form data with selected environments
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"], // Only env-2 selected
|
||||
},
|
||||
{
|
||||
project: "project-2",
|
||||
environments: ["env-3"], // Only env-3 selected
|
||||
},
|
||||
],
|
||||
};
|
||||
return fn(mockFormData);
|
||||
};
|
||||
},
|
||||
}),
|
||||
useFieldArray: () => ({
|
||||
fields: [{ project: "project-1" }, { project: "project-2" }],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockSurvey = {
|
||||
id: "survey-1",
|
||||
name: "mockSurvey",
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -90,11 +147,16 @@ const mockProjects = [
|
||||
describe("CopySurveyForm", () => {
|
||||
const mockSetOpen = vi.fn();
|
||||
const mockOnCancel = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const mockOnSurveysCopied = vi.fn();
|
||||
|
||||
// Get references to the mocked functions
|
||||
const mockCopySurveyAction = vi.mocked(copySurveyToOtherEnvironmentAction);
|
||||
const mockToastSuccess = vi.mocked(toast.success);
|
||||
const mockToastError = vi.mocked(toast.error);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(copySurveyToOtherEnvironmentAction).mockResolvedValue({ data: { id: "new-survey-id" } });
|
||||
mockSubmitHandler = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -111,22 +173,14 @@ describe("CopySurveyForm", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if project names are rendered
|
||||
expect(screen.getByText("Project 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project 2")).toBeInTheDocument();
|
||||
|
||||
// Check if environment types are rendered
|
||||
expect(screen.getAllByText("development").length).toBe(2);
|
||||
expect(screen.getAllByText("development").length).toBe(1);
|
||||
expect(screen.getAllByText("production").length).toBe(2);
|
||||
|
||||
// Check if checkboxes are rendered for each environment
|
||||
expect(screen.getByTestId("env-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("env-2")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("env-3")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("env-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls onCancel when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
@@ -142,45 +196,252 @@ describe("CopySurveyForm", () => {
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("toggles environment selection when checkbox is clicked", async () => {
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
describe("onSubmit function", () => {
|
||||
test("should handle successful operations", async () => {
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: { id: "new-survey-1", environmentId: "env-2" },
|
||||
});
|
||||
|
||||
// Select multiple environments
|
||||
await user.click(screen.getByTestId("env-2"));
|
||||
await user.click(screen.getByTestId("env-3"));
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
// Submit the form
|
||||
await user.click(screen.getByTestId("button-submit"));
|
||||
// Call the submit handler directly
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"],
|
||||
},
|
||||
{
|
||||
project: "project-2",
|
||||
environments: ["env-3"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Just verify the form can be submitted (integration testing is complex with mocked components)
|
||||
expect(screen.getByTestId("button-submit")).toBeInTheDocument();
|
||||
});
|
||||
await mockSubmitHandler(mockFormData);
|
||||
|
||||
test("submits form with selected environments", async () => {
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(mockCopySurveyAction).toHaveBeenCalledTimes(2);
|
||||
expect(mockCopySurveyAction).toHaveBeenCalledWith({
|
||||
environmentId: "env-1",
|
||||
surveyId: "survey-1",
|
||||
targetEnvironmentId: "env-2",
|
||||
});
|
||||
expect(mockCopySurveyAction).toHaveBeenCalledWith({
|
||||
environmentId: "env-1",
|
||||
surveyId: "survey-1",
|
||||
targetEnvironmentId: "env-3",
|
||||
});
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("environments.surveys.copy_survey_success");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Select environments
|
||||
await user.click(screen.getByTestId("env-2"));
|
||||
await user.click(screen.getByTestId("env-4"));
|
||||
test("should handle partial success with mixed results", async () => {
|
||||
mockCopySurveyAction
|
||||
.mockResolvedValueOnce({ data: { id: "new-survey-1", environmentId: "env-2" } })
|
||||
.mockResolvedValueOnce({ serverError: "Failed to copy" });
|
||||
|
||||
// Submit the form
|
||||
await user.click(screen.getByTestId("button-submit"));
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
// Just verify basic form functionality (complex integration testing with mocked components is challenging)
|
||||
expect(screen.getByTestId("button-submit")).toBeInTheDocument();
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"],
|
||||
},
|
||||
{
|
||||
project: "project-2",
|
||||
environments: ["env-3"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mockSubmitHandler(mockFormData);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"Partially successful: 1 success, 1 error",
|
||||
expect.objectContaining({
|
||||
icon: expect.anything(),
|
||||
})
|
||||
);
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"[Project 2] - [development] - Failed to copy",
|
||||
expect.objectContaining({
|
||||
duration: 2000,
|
||||
})
|
||||
);
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle all failed operations", async () => {
|
||||
mockCopySurveyAction
|
||||
.mockResolvedValueOnce({ serverError: "Server error 1" })
|
||||
.mockResolvedValueOnce({ validationErrors: { surveyId: { _errors: ["Invalid survey ID"] } } });
|
||||
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"],
|
||||
},
|
||||
{
|
||||
project: "project-2",
|
||||
environments: ["env-3"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mockSubmitHandler(mockFormData);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"[Project 1] - [production] - Server error 1",
|
||||
expect.objectContaining({
|
||||
duration: 2000,
|
||||
})
|
||||
);
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"[Project 2] - [development] - Validation error",
|
||||
expect.objectContaining({
|
||||
duration: 4000,
|
||||
})
|
||||
);
|
||||
expect(mockToastSuccess).not.toHaveBeenCalled();
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle exceptions during form submission", async () => {
|
||||
mockCopySurveyAction.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mockSubmitHandler(mockFormData);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith("environments.surveys.copy_survey_error");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle staggered error toast durations", async () => {
|
||||
mockCopySurveyAction
|
||||
.mockResolvedValueOnce({ serverError: "Error 1" })
|
||||
.mockResolvedValueOnce({ serverError: "Error 2" })
|
||||
.mockResolvedValueOnce({ serverError: "Error 3" });
|
||||
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"],
|
||||
},
|
||||
{
|
||||
project: "project-2",
|
||||
environments: ["env-3", "env-4"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mockSubmitHandler(mockFormData);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"[Project 1] - [production] - Error 1",
|
||||
expect.objectContaining({ duration: 2000 })
|
||||
);
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"[Project 2] - [development] - Error 2",
|
||||
expect.objectContaining({ duration: 4000 })
|
||||
);
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"[Project 2] - [production] - Error 3",
|
||||
expect.objectContaining({ duration: 6000 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should not call onSurveysCopied when it's not provided", async () => {
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: { id: "new-survey-1", environmentId: "env-1" },
|
||||
});
|
||||
|
||||
render(
|
||||
<CopySurveyForm
|
||||
defaultProjects={mockProjects}
|
||||
survey={mockSurvey}
|
||||
onCancel={mockOnCancel}
|
||||
setOpen={mockSetOpen}
|
||||
// onSurveysCopied not provided
|
||||
/>
|
||||
);
|
||||
|
||||
const mockFormData = {
|
||||
projects: [
|
||||
{
|
||||
project: "project-1",
|
||||
environments: ["env-2"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await mockSubmitHandler(mockFormData);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
// Should not throw an error even when onSurveysCopied is not provided
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,22 +10,100 @@ import { FormControl, FormField, FormItem, FormProvider } from "@/modules/ui/com
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface ICopySurveyFormProps {
|
||||
defaultProjects: TUserProject[];
|
||||
survey: TSurvey;
|
||||
onCancel: () => void;
|
||||
setOpen: (value: boolean) => void;
|
||||
interface CopySurveyFormProps {
|
||||
readonly defaultProjects: TUserProject[];
|
||||
readonly survey: TSurvey;
|
||||
readonly onCancel: () => void;
|
||||
readonly setOpen: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: ICopySurveyFormProps) => {
|
||||
interface EnvironmentCheckboxProps {
|
||||
readonly environmentId: string;
|
||||
readonly environmentType: string;
|
||||
readonly fieldValue: string[];
|
||||
readonly onChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
function EnvironmentCheckbox({
|
||||
environmentId,
|
||||
environmentType,
|
||||
fieldValue,
|
||||
onChange,
|
||||
}: EnvironmentCheckboxProps) {
|
||||
const handleCheckedChange = () => {
|
||||
if (fieldValue.includes(environmentId)) {
|
||||
onChange(fieldValue.filter((id) => id !== environmentId));
|
||||
} else {
|
||||
onChange([...fieldValue, environmentId]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
checked={fieldValue.includes(environmentId)}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
id={environmentId}
|
||||
/>
|
||||
<Label htmlFor={environmentId}>
|
||||
<p className="text-sm font-medium capitalize text-slate-900">{environmentType}</p>
|
||||
</Label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface EnvironmentCheckboxGroupProps {
|
||||
readonly project: TUserProject;
|
||||
readonly form: ReturnType<typeof useForm<TSurveyCopyFormData>>;
|
||||
readonly projectIndex: number;
|
||||
}
|
||||
|
||||
function EnvironmentCheckboxGroup({ project, form, projectIndex }: EnvironmentCheckboxGroupProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{project.environments.map((environment) => (
|
||||
<FormField
|
||||
key={environment.id}
|
||||
control={form.control}
|
||||
name={`projects.${projectIndex}.environments`}
|
||||
render={({ field }) => (
|
||||
<EnvironmentCheckbox
|
||||
environmentId={environment.id}
|
||||
environmentType={environment.type}
|
||||
fieldValue={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: CopySurveyFormProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const filteredProjects = defaultProjects.map((project) => ({
|
||||
...project,
|
||||
environments: project.environments.filter((env) => env.id !== survey.environmentId),
|
||||
}));
|
||||
|
||||
const form = useForm<TSurveyCopyFormData>({
|
||||
resolver: zodResolver(ZSurveyCopyFormValidation),
|
||||
defaultValues: {
|
||||
projects: defaultProjects.map((project) => ({
|
||||
projects: filteredProjects.map((project) => ({
|
||||
project: project.id,
|
||||
environments: [],
|
||||
})),
|
||||
@@ -37,32 +115,79 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TSurveyCopyFormData) => {
|
||||
async function onSubmit(data: TSurveyCopyFormData) {
|
||||
const filteredData = data.projects.filter((project) => project.environments.length > 0);
|
||||
|
||||
try {
|
||||
filteredData.forEach(async (project) => {
|
||||
project.environments.forEach(async (environment) => {
|
||||
const result = await copySurveyToOtherEnvironmentAction({
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
targetEnvironmentId: environment,
|
||||
});
|
||||
const copyOperationsWithMetadata = filteredData.flatMap((projectData) => {
|
||||
const project = filteredProjects.find((p) => p.id === projectData.project);
|
||||
return projectData.environments.map((environmentId) => {
|
||||
const environment =
|
||||
project?.environments[0]?.id === environmentId
|
||||
? project?.environments[0]
|
||||
: project?.environments[1];
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.surveys.copy_survey_success"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
return {
|
||||
operation: copySurveyToOtherEnvironmentAction({
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
targetEnvironmentId: environmentId,
|
||||
}),
|
||||
projectName: project?.name ?? "Unknown Project",
|
||||
environmentType: environment?.type ?? "unknown",
|
||||
environmentId,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.all(copyOperationsWithMetadata.map((item) => item.operation));
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
const errorsIndexes: number[] = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result?.data) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorsIndexes.push(index);
|
||||
errorCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (successCount > 0) {
|
||||
if (errorCount === 0) {
|
||||
toast.success(t("environments.surveys.copy_survey_success"));
|
||||
} else {
|
||||
toast.error(
|
||||
t("environments.surveys.copy_survey_partially_success", {
|
||||
success: successCount,
|
||||
error: errorCount,
|
||||
}),
|
||||
{
|
||||
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errorsIndexes.length > 0) {
|
||||
errorsIndexes.forEach((index, idx) => {
|
||||
const { projectName, environmentType } = copyOperationsWithMetadata[index];
|
||||
const result = results[index];
|
||||
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(`[${projectName}] - [${environmentType}] - ${errorMessage}`, {
|
||||
duration: 2000 + 2000 * idx,
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.copy_survey_error"));
|
||||
} finally {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
@@ -71,58 +196,16 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
|
||||
className="relative flex h-full w-full flex-col gap-8 overflow-y-auto bg-white p-4">
|
||||
<div className="space-y-8 pb-12">
|
||||
{formFields.fields.map((field, projectIndex) => {
|
||||
const project = defaultProjects.find((project) => project.id === field.project);
|
||||
const project = filteredProjects.find((project) => project.id === field.project);
|
||||
if (!project) return null;
|
||||
|
||||
return (
|
||||
<div key={project?.id}>
|
||||
<div key={project.id}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-fit">
|
||||
<p className="text-base font-semibold text-slate-900">{project?.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{project?.environments.map((environment) => {
|
||||
return (
|
||||
<FormField
|
||||
key={environment.id}
|
||||
control={form.control}
|
||||
name={`projects.${projectIndex}.environments`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormControl>
|
||||
<>
|
||||
<Checkbox
|
||||
{...field}
|
||||
type="button"
|
||||
onCheckedChange={() => {
|
||||
if (field.value.includes(environment.id)) {
|
||||
field.onChange(
|
||||
field.value.filter((id: string) => id !== environment.id)
|
||||
);
|
||||
} else {
|
||||
field.onChange([...field.value, environment.id]);
|
||||
}
|
||||
}}
|
||||
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
id={environment.id}
|
||||
/>
|
||||
<Label htmlFor={environment.id}>
|
||||
<p className="text-sm font-medium capitalize text-slate-900">
|
||||
{environment.type}
|
||||
</p>
|
||||
</Label>
|
||||
</>
|
||||
</FormControl>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<p className="text-base font-semibold text-slate-900">{project.name}</p>
|
||||
</div>
|
||||
<EnvironmentCheckboxGroup project={project} form={form} projectIndex={projectIndex} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -133,7 +216,6 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
|
||||
<Button type="button" onClick={onCancel} variant="ghost">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
<Button type="submit">{t("environments.surveys.copy_survey")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,9 @@ interface SurveyCardProps {
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
publicDomain: string;
|
||||
duplicateSurvey: (survey: TSurvey) => void;
|
||||
deleteSurvey: (surveyId: string) => void;
|
||||
locale: TUserLocale;
|
||||
onSurveysCopied?: () => void;
|
||||
}
|
||||
export const SurveyCard = ({
|
||||
survey,
|
||||
@@ -27,8 +27,8 @@ export const SurveyCard = ({
|
||||
isReadOnly,
|
||||
publicDomain,
|
||||
deleteSurvey,
|
||||
duplicateSurvey,
|
||||
locale,
|
||||
onSurveysCopied,
|
||||
}: SurveyCardProps) => {
|
||||
const { t } = useTranslate();
|
||||
const surveyStatusLabel = (() => {
|
||||
@@ -106,8 +106,8 @@ export const SurveyCard = ({
|
||||
disabled={isDraftAndReadOnly}
|
||||
refreshSingleUseId={refreshSingleUseId}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
duplicateSurvey={duplicateSurvey}
|
||||
deleteSurvey={deleteSurvey}
|
||||
onSurveysCopied={onSurveysCopied}
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -86,7 +86,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={{ ...fakeSurvey, status: "completed" }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={mockRefresh}
|
||||
duplicateSurvey={mockDuplicateSurvey}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
@@ -118,7 +117,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={vi.fn()}
|
||||
disabled={false}
|
||||
isSurveyCreationDeletionDisabled={false}
|
||||
@@ -158,7 +156,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
@@ -181,7 +178,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={{ ...fakeSurvey, responseCount: 0 }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
@@ -200,14 +196,12 @@ describe("SurveyDropDownMenu", () => {
|
||||
});
|
||||
|
||||
test("<DropdownMenuItem> renders and triggers actions correctly", async () => {
|
||||
const mockDuplicateSurvey = vi.fn();
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={mockDuplicateSurvey}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
@@ -220,21 +214,15 @@ describe("SurveyDropDownMenu", () => {
|
||||
const duplicateButton = screen.getByText("common.duplicate");
|
||||
expect(duplicateButton).toBeInTheDocument();
|
||||
await userEvent.click(duplicateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDuplicateSurvey).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("<EditPublicSurveyAlertDialog> displays and handles actions correctly", async () => {
|
||||
const mockDuplicateSurvey = vi.fn();
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={{ ...fakeSurvey, responseCount: 5 }}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={mockDuplicateSurvey}
|
||||
deleteSurvey={vi.fn()}
|
||||
/>
|
||||
);
|
||||
@@ -260,10 +248,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
const duplicateButton = screen.getByRole("button", { name: "common.duplicate" });
|
||||
expect(duplicateButton).toBeInTheDocument();
|
||||
await userEvent.click(duplicateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDuplicateSurvey).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDeleteSurvey", () => {
|
||||
@@ -281,7 +265,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
@@ -317,7 +300,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
@@ -354,7 +336,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
@@ -391,7 +372,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
@@ -429,7 +409,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
@@ -484,7 +463,6 @@ describe("SurveyDropDownMenu", () => {
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -41,8 +41,8 @@ interface SurveyDropDownMenuProps {
|
||||
refreshSingleUseId: () => Promise<string | undefined>;
|
||||
disabled?: boolean;
|
||||
isSurveyCreationDeletionDisabled?: boolean;
|
||||
duplicateSurvey: (survey: TSurvey) => void;
|
||||
deleteSurvey: (surveyId: string) => void;
|
||||
onSurveysCopied?: () => void;
|
||||
}
|
||||
|
||||
export const SurveyDropDownMenu = ({
|
||||
@@ -53,7 +53,7 @@ export const SurveyDropDownMenu = ({
|
||||
disabled,
|
||||
isSurveyCreationDeletionDisabled,
|
||||
deleteSurvey,
|
||||
duplicateSurvey,
|
||||
onSurveysCopied,
|
||||
}: SurveyDropDownMenuProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
@@ -102,13 +102,14 @@ export const SurveyDropDownMenu = ({
|
||||
surveyId,
|
||||
targetEnvironmentId: environmentId,
|
||||
});
|
||||
router.refresh();
|
||||
|
||||
if (duplicatedSurveyResponse?.data) {
|
||||
const transformedDuplicatedSurvey = await getSurveyAction({
|
||||
surveyId: duplicatedSurveyResponse.data.id,
|
||||
});
|
||||
if (transformedDuplicatedSurvey?.data) duplicateSurvey(transformedDuplicatedSurvey.data);
|
||||
if (transformedDuplicatedSurvey?.data) {
|
||||
onSurveysCopied?.();
|
||||
}
|
||||
toast.success(t("environments.surveys.survey_duplicated_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(duplicatedSurveyResponse);
|
||||
|
||||
@@ -341,27 +341,6 @@ describe("SurveysList", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("handleDuplicateSurvey adds the duplicated survey to the beginning of the list", async () => {
|
||||
const initialSurvey = { ...surveyMock, id: "s1", name: "Original Survey" };
|
||||
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: [initialSurvey] });
|
||||
const user = userEvent.setup();
|
||||
render(<SurveysList {...defaultProps} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Original Survey")).toBeInTheDocument());
|
||||
|
||||
const duplicateButtonS1 = screen.getByTestId("duplicate-s1");
|
||||
// The mock SurveyCard calls duplicateSurvey(survey) with the original survey object.
|
||||
await user.click(duplicateButtonS1);
|
||||
|
||||
await waitFor(() => {
|
||||
const surveyCards = screen.getAllByTestId(/survey-card-/);
|
||||
expect(surveyCards).toHaveLength(2);
|
||||
// Both cards will show "Original Survey" as the object is prepended.
|
||||
expect(surveyCards[0]).toHaveTextContent("Original Survey");
|
||||
expect(surveyCards[1]).toHaveTextContent("Original Survey");
|
||||
});
|
||||
});
|
||||
|
||||
test("applies useAutoAnimate ref to the survey list container", async () => {
|
||||
const surveysData = [{ ...surveyMock, id: "s1" }];
|
||||
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: surveysData });
|
||||
|
||||
@@ -46,6 +46,7 @@ export const SurveysList = ({
|
||||
const [surveys, setSurveys] = useState<TSurvey[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
const [surveyFilters, setSurveyFilters] = useState<TSurveyFilters>(initialFilters);
|
||||
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
|
||||
@@ -98,7 +99,7 @@ export const SurveysList = ({
|
||||
};
|
||||
fetchInitialSurveys();
|
||||
}
|
||||
}, [environmentId, surveysLimit, filters, isFilterInitialized]);
|
||||
}, [environmentId, surveysLimit, filters, isFilterInitialized, refreshTrigger]);
|
||||
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
setIsFetching(true);
|
||||
@@ -126,10 +127,9 @@ export const SurveysList = ({
|
||||
if (newSurveys.length === 0) setIsFetching(true);
|
||||
};
|
||||
|
||||
const handleDuplicateSurvey = async (survey: TSurvey) => {
|
||||
const newSurveys = [survey, ...surveys];
|
||||
setSurveys(newSurveys);
|
||||
};
|
||||
const triggerRefresh = useCallback(() => {
|
||||
setRefreshTrigger((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -158,9 +158,9 @@ export const SurveysList = ({
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
publicDomain={publicDomain}
|
||||
duplicateSurvey={handleDuplicateSurvey}
|
||||
deleteSurvey={handleDeleteSurvey}
|
||||
locale={locale}
|
||||
onSurveysCopied={triggerRefresh}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,14 +3,6 @@ import { render } from "@testing-library/react";
|
||||
import { type MockedFunction, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ClientLogout } from "./index";
|
||||
|
||||
// Mock the localStorage
|
||||
const mockRemoveItem = vi.fn();
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
removeItem: mockRemoveItem,
|
||||
},
|
||||
});
|
||||
|
||||
// Mock next-auth/react
|
||||
const mockSignOut = vi.fn();
|
||||
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
|
||||
@@ -37,6 +29,7 @@ describe("ClientLogout", () => {
|
||||
redirectUrl: "/auth/login",
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,14 +43,10 @@ describe("ClientLogout", () => {
|
||||
redirectUrl: "/auth/login",
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("removes environment ID from localStorage", () => {
|
||||
render(<ClientLogout />);
|
||||
expect(mockRemoveItem).toHaveBeenCalledWith("formbricks-environment-id");
|
||||
});
|
||||
|
||||
test("renders null", () => {
|
||||
const { container } = render(<ClientLogout />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { useEffect } from "react";
|
||||
|
||||
@@ -8,12 +7,12 @@ export const ClientLogout = () => {
|
||||
const { signOut: signOutWithAudit } = useSignOut();
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
signOutWithAudit({
|
||||
reason: "forced_logout",
|
||||
redirectUrl: "/auth/login",
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
});
|
||||
return null;
|
||||
|
||||
@@ -21,16 +21,27 @@ export const OptionsSwitch = ({
|
||||
const [highlightStyle, setHighlightStyle] = useState({});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const activeElement = containerRef.current.querySelector(`[data-value="${currentOption}"]`);
|
||||
if (activeElement) {
|
||||
const { offsetLeft, offsetWidth } = activeElement as HTMLElement;
|
||||
setHighlightStyle({
|
||||
left: `${offsetLeft}px`,
|
||||
width: `${offsetWidth}px`,
|
||||
});
|
||||
const updateHighlight = () => {
|
||||
if (containerRef.current) {
|
||||
const activeElement = containerRef.current.querySelector(`[data-value="${currentOption}"]`);
|
||||
if (activeElement) {
|
||||
const { offsetLeft, offsetWidth } = activeElement as HTMLElement;
|
||||
setHighlightStyle({
|
||||
left: `${offsetLeft}px`,
|
||||
width: `${offsetWidth}px`,
|
||||
});
|
||||
} else {
|
||||
// Hide highlight if no matching element found
|
||||
setHighlightStyle({ opacity: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// Initial call
|
||||
updateHighlight();
|
||||
|
||||
// Listen to resize
|
||||
window.addEventListener("resize", updateHighlight);
|
||||
return () => window.removeEventListener("resize", updateHighlight);
|
||||
}, [currentOption]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,7 +20,7 @@ const nextConfig = {
|
||||
cacheMaxMemorySize: 0, // disable default in-memory caching
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
productionBrowserSourceMaps: false,
|
||||
productionBrowserSourceMaps: true,
|
||||
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
|
||||
outputFileTracingIncludes: {
|
||||
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
|
||||
@@ -425,6 +425,7 @@ const sentryOptions = {
|
||||
|
||||
org: "formbricks",
|
||||
project: "formbricks-cloud",
|
||||
environment: process.env.SENTRY_ENVIRONMENT,
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: true,
|
||||
@@ -434,11 +435,23 @@ const sentryOptions = {
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Disable automatic release management
|
||||
automaticVercelMonitors: false,
|
||||
autoUploadSourceMaps: false,
|
||||
hideSourceMaps: false,
|
||||
|
||||
// Don't automatically create releases - we handle this in GitHub Actions
|
||||
release: {
|
||||
create: false,
|
||||
deploy: false,
|
||||
setCommits: false,
|
||||
},
|
||||
};
|
||||
|
||||
const exportConfig =
|
||||
process.env.SENTRY_DSN && process.env.NODE_ENV === "production"
|
||||
? withSentryConfig(nextConfig, sentryOptions)
|
||||
: nextConfig;
|
||||
(process.env.SENTRY_DSN && process.env.NODE_ENV === "production")
|
||||
? withSentryConfig(nextConfig, sentryOptions) :
|
||||
nextConfig;
|
||||
|
||||
export default exportConfig;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"test": "dotenv -e ../../.env -- vitest run",
|
||||
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
|
||||
"generate-api-specs": "dotenv -e ../../.env tsx ./modules/api/v2/openapi-document.ts > ../../docs/api-v2-reference/openapi.yml",
|
||||
"merge-client-endpoints": "tsx ./scripts/merge-client-endpoints.ts",
|
||||
"merge-client-endpoints": "tsx ./scripts/openapi/merge-client-endpoints.ts",
|
||||
"generate-and-merge-api-specs": "npm run generate-api-specs && npm run merge-client-endpoints"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
13
apps/web/scripts/docker/next-start.sh
Normal file
13
apps/web/scripts/docker/next-start.sh
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
export NODE_ENV=production
|
||||
if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then
|
||||
echo "Starting cron jobs...";
|
||||
supercronic -quiet /app/docker/cronjobs &
|
||||
else
|
||||
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0";
|
||||
fi;
|
||||
(cd packages/database && npm run db:migrate:deploy) &&
|
||||
(cd packages/database && npm run db:create-saml-database:deploy) &&
|
||||
exec node apps/web/server.js
|
||||
16
apps/web/scripts/docker/read-secrets.sh
Normal file
16
apps/web/scripts/docker/read-secrets.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
if [ -f "/run/secrets/database_url" ]; then
|
||||
export DATABASE_URL=$(cat /run/secrets/database_url)
|
||||
else
|
||||
echo "DATABASE_URL secret not found. Build may fail if this is required."
|
||||
fi
|
||||
|
||||
if [ -f "/run/secrets/encryption_key" ]; then
|
||||
export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)
|
||||
else
|
||||
echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -2,7 +2,7 @@
|
||||
// The config you add here will be used whenever one of the edge features is loaded.
|
||||
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import { SENTRY_DSN } from "@/lib/constants";
|
||||
import { SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
@@ -11,6 +11,8 @@ if (SENTRY_DSN) {
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
release: SENTRY_RELEASE,
|
||||
environment: SENTRY_ENVIRONMENT,
|
||||
|
||||
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
|
||||
tracesSampleRate: 0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file configures the initialization of Sentry on the server.
|
||||
// The config you add here will be used whenever the server handles a request.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import { SENTRY_DSN } from "@/lib/constants";
|
||||
import { SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASE } from "@/lib/constants";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
@@ -10,6 +10,8 @@ if (SENTRY_DSN) {
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
release: SENTRY_RELEASE,
|
||||
environment: SENTRY_ENVIRONMENT,
|
||||
|
||||
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
|
||||
tracesSampleRate: 0,
|
||||
|
||||
@@ -82,6 +82,8 @@ x-environment: &environment
|
||||
# SENTRY_DSN:
|
||||
# It's used for authentication when uploading source maps to Sentry, to make errors more readable.
|
||||
# SENTRY_AUTH_TOKEN:
|
||||
# The SENTRY_ENVIRONMENT is used to identify the environment in Sentry.
|
||||
# SENTRY_ENVIRONMENT:
|
||||
|
||||
################################################### OPTIONAL (STORAGE) ###################################################
|
||||
|
||||
|
||||
@@ -83,16 +83,18 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| Email follow-ups | ✅ | ✅ |
|
||||
| Multi-language UI | ✅ | ✅ |
|
||||
| All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ |
|
||||
| Domain Split Configuration | ✅ | ✅ |
|
||||
| Hide "Powered by Formbricks" | ❌ | ✅ |
|
||||
| Whitelabel email follow-ups | ❌ | ✅ |
|
||||
| Teams & access roles | ❌ | ✅ |
|
||||
| Contact management & segments | ❌ | ✅ |
|
||||
| Multi-language surveys | ❌ | ✅ |
|
||||
| Audit Logs | ❌ | ✅ |
|
||||
| OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
|
||||
| SAML SSO | ❌ | ✅ |
|
||||
| Spam protection (ReCaptchaV3) | ❌ | ✅ |
|
||||
| Two-factor authentication | ❌ | ✅ |
|
||||
| Custom 'Project' count | ❌ | ✅ |
|
||||
| Spam protection (ReCaptchaV3) | ❌ | ✅ |
|
||||
| Two-factor authentication | ❌ | ✅ |
|
||||
| Custom 'Project' count | ❌ | ✅ |
|
||||
| White-glove onboarding | ❌ | ✅ |
|
||||
| Support SLAs | ❌ | ✅ |
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
|
||||
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
|
||||
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
|
||||
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
|
||||
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
|
||||
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
|
||||
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
|
||||
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
@@ -68,6 +68,7 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| DOCKER_CRON_ENABLED | Controls whether cron jobs run in the Docker image. Set to 0 to disable (useful for cluster setups). | optional | 1 |
|
||||
| DEFAULT_TEAM_ID | Default team ID for new users. | optional | |
|
||||
| SENTRY_DSN | Set this to track errors and monitor performance in Sentry. | optional | |
|
||||
| SENTRY_ENVIRONMENT | Set this to identify the environment in Sentry | optional | |
|
||||
| SENTRY_AUTH_TOKEN | Set this if you want to make errors more readable in Sentry. | optional | |
|
||||
| SESSION_MAX_AGE | Configure the maximum age for the session in seconds. | optional | 86400 (24 hours) |
|
||||
| USER_MANAGEMENT_MINIMUM_ROLE | Set this to control which roles can access user management features. Accepted values: "owner", "manager", "disabled" | optional | manager |
|
||||
|
||||
@@ -3,23 +3,41 @@
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"main": "./src/index.ts",
|
||||
"main": "./dist/index.cjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src"
|
||||
"dist",
|
||||
"schema.prisma",
|
||||
"migration"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./types/*": {
|
||||
"import": "./types/*.ts"
|
||||
},
|
||||
"./zod/*": {
|
||||
"import": "./zod/*.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules",
|
||||
"db:migrate:deploy": "env DATABASE_URL=\"${MIGRATE_DATABASE_URL:-$DATABASE_URL}\" tsx ./src/scripts/apply-migrations.ts",
|
||||
"db:migrate:dev": "dotenv -e ../../.env -- sh -c \"pnpm prisma generate && tsx ./src/scripts/apply-migrations.ts\"",
|
||||
"db:create-saml-database:deploy": "env SAML_DATABASE_URL=\"${SAML_DATABASE_URL}\" tsx ./src/scripts/create-saml-database.ts",
|
||||
"db:create-saml-database:dev": "dotenv -e ../../.env -- tsx ./src/scripts/create-saml-database.ts",
|
||||
"clean": "rimraf .turbo node_modules dist",
|
||||
"build": "pnpm generate && vite build",
|
||||
"dev": "vite build --watch",
|
||||
"db:migrate:deploy": "env DATABASE_URL=\"${MIGRATE_DATABASE_URL:-$DATABASE_URL}\" node ./dist/scripts/apply-migrations.js",
|
||||
"db:migrate:dev": "dotenv -e ../../.env -- sh -c \"pnpm prisma generate && node ./dist/scripts/apply-migrations.js\"",
|
||||
"db:create-saml-database:deploy": "env SAML_DATABASE_URL=\"${SAML_DATABASE_URL}\" node ./dist/scripts/create-saml-database.js",
|
||||
"db:create-saml-database:dev": "dotenv -e ../../.env -- node ./dist/scripts/create-saml-database.js",
|
||||
"db:push": "prisma db push --accept-data-loss",
|
||||
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev",
|
||||
"db:start": "pnpm db:setup",
|
||||
"format": "prisma format",
|
||||
"generate": "prisma generate",
|
||||
"lint": "eslint ./src --fix",
|
||||
"build": "pnpm generate",
|
||||
"generate-data-migration": "tsx ./src/scripts/generate-data-migration.ts",
|
||||
"create-migration": "dotenv -e ../../.env -- tsx ./src/scripts/create-migration.ts"
|
||||
},
|
||||
@@ -34,8 +52,11 @@
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"dotenv-cli": "8.0.0",
|
||||
"glob": "11.0.2",
|
||||
"prisma": "6.7.0",
|
||||
"prisma-json-types-generator": "3.4.1",
|
||||
"ts-node": "10.9.2"
|
||||
"ts-node": "10.9.2",
|
||||
"vite": "6.3.5",
|
||||
"vite-plugin-dts": "4.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prismaClientSingleton = () => {
|
||||
const prismaClientSingleton = (): PrismaClient => {
|
||||
return new PrismaClient({
|
||||
datasources: { db: { url: process.env.DATABASE_URL } },
|
||||
...(process.env.DEBUG === "1" && {
|
||||
@@ -15,6 +15,6 @@ const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClientSingleton | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
|
||||
export const prisma: PrismaClient = globalForPrisma.prisma ?? prismaClientSingleton();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
|
||||
@@ -2,10 +2,14 @@ import { exec } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { applyMigrations } from "./migration-runner";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
|
||||
@@ -2,8 +2,12 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
|
||||
@@ -2,9 +2,12 @@ import { type Prisma, PrismaClient } from "@prisma/client";
|
||||
import { exec } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface DataMigrationContext {
|
||||
@@ -24,7 +27,12 @@ export interface MigrationScript {
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
|
||||
const MIGRATIONS_DIR = path.resolve(__dirname, "../../migration");
|
||||
|
||||
// Determine if we're running from built or source code
|
||||
const isBuilt = __filename.split(path.sep).includes("dist");
|
||||
const MIGRATIONS_DIR = isBuilt
|
||||
? path.resolve(__dirname, "../migration") // From dist/scripts to dist/migration
|
||||
: path.resolve(__dirname, "../../migration"); // From src/scripts to migration
|
||||
const PRISMA_MIGRATIONS_DIR = path.resolve(__dirname, "../../migrations");
|
||||
|
||||
const runMigrations = async (migrations: MigrationScript[]): Promise<void> => {
|
||||
@@ -194,11 +202,13 @@ const loadMigrations = async (): Promise<MigrationScript[]> => {
|
||||
const files = await fs.readdir(migrationPath);
|
||||
|
||||
const hasSchemaMigration = files.includes("migration.sql");
|
||||
const hasDataMigration = files.includes("migration.ts");
|
||||
// Check for the appropriate data migration file extension based on build status
|
||||
const dataMigrationFileName = isBuilt ? "migration.js" : "migration.ts";
|
||||
const hasDataMigration = files.includes(dataMigrationFileName);
|
||||
|
||||
if (hasSchemaMigration && hasDataMigration) {
|
||||
throw new Error(
|
||||
`Migration directory ${dirName} has both migration.sql and migration.ts. This should not happen.`
|
||||
`Migration directory ${dirName} has both migration.sql and ${dataMigrationFileName}. This should not happen.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,7 +243,8 @@ const loadMigrations = async (): Promise<MigrationScript[]> => {
|
||||
}
|
||||
|
||||
// It's a data migration, dynamically import and extract the scripts
|
||||
const modulePath = path.join(migrationPath, "migration.ts");
|
||||
// Use .js extension when running from built code, .ts when running from source
|
||||
const modulePath = path.join(migrationPath, dataMigrationFileName);
|
||||
const mod = (await import(modulePath)) as Record<string, MigrationScript | undefined>;
|
||||
|
||||
// Check each export in the module for a DataMigrationScript (type: "data")
|
||||
@@ -245,7 +256,7 @@ const loadMigrations = async (): Promise<MigrationScript[]> => {
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`Migration directory ${dirName} doesn't have migration.sql or data-migration.ts. Skipping...`
|
||||
`Migration directory ${dirName} doesn't have migration.sql or ${dataMigrationFileName}. Skipping...`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -271,7 +282,11 @@ const loadMigrations = async (): Promise<MigrationScript[]> => {
|
||||
export async function applyMigrations(): Promise<void> {
|
||||
try {
|
||||
const allMigrations = await loadMigrations();
|
||||
logger.info(`Loaded ${allMigrations.length.toString()} migrations from ${MIGRATIONS_DIR}`);
|
||||
logger.info(
|
||||
`Loaded ${allMigrations.length.toString()} migrations from ${MIGRATIONS_DIR} (source: ${
|
||||
isBuilt ? "dist" : "src"
|
||||
})`
|
||||
);
|
||||
await runMigrations(allMigrations);
|
||||
} catch (error) {
|
||||
await prisma.$disconnect();
|
||||
|
||||
80
packages/database/vite.config.ts
Normal file
80
packages/database/vite.config.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { promises as fs } from "fs";
|
||||
import { glob } from "glob";
|
||||
import { dirname, resolve } from "path";
|
||||
import { Plugin, UserConfig, defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
const copySqlMigrationsPlugin: Plugin = {
|
||||
name: "copy-sql-migrations",
|
||||
async writeBundle() {
|
||||
const sqlFiles = await glob("migration/**/migration.sql", { cwd: __dirname });
|
||||
|
||||
await Promise.all(
|
||||
sqlFiles.map(async (file) => {
|
||||
const srcPath = resolve(__dirname, file);
|
||||
const destPath = resolve(__dirname, "dist", file);
|
||||
await fs.mkdir(dirname(destPath), { recursive: true });
|
||||
await fs.copyFile(srcPath, destPath);
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig(async (): Promise<UserConfig> => {
|
||||
const migrationTsFiles = await glob("migration/**/migration.ts", { cwd: __dirname });
|
||||
const migrationEntries = migrationTsFiles.reduce((acc: Record<string, string>, file: string) => {
|
||||
const dir = dirname(file);
|
||||
const entryName = `${dir}/migration`;
|
||||
acc[entryName] = resolve(__dirname, file);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, "src/index.ts"),
|
||||
"scripts/apply-migrations": resolve(__dirname, "src/scripts/apply-migrations.ts"),
|
||||
"scripts/create-saml-database": resolve(__dirname, "src/scripts/create-saml-database.ts"),
|
||||
"scripts/migration-runner": resolve(__dirname, "src/scripts/migration-runner.ts"),
|
||||
...migrationEntries,
|
||||
},
|
||||
output: [
|
||||
{
|
||||
format: "esm",
|
||||
entryFileNames: "[name].js",
|
||||
chunkFileNames: "[name].js",
|
||||
},
|
||||
{
|
||||
format: "cjs",
|
||||
entryFileNames: "[name].cjs",
|
||||
chunkFileNames: "[name].cjs",
|
||||
},
|
||||
],
|
||||
external: [
|
||||
// External dependencies that should not be bundled
|
||||
"@prisma/client",
|
||||
"zod",
|
||||
"zod-openapi",
|
||||
"@paralleldrive/cuid2",
|
||||
],
|
||||
},
|
||||
emptyOutDir: true,
|
||||
ssr: true, // Server-side rendering mode for Node.js
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
rollupTypes: false,
|
||||
include: ["src/**/*"],
|
||||
exclude: ["src/**/*.test.ts", "src/**/*.spec.ts"],
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
copySqlMigrationsPlugin,
|
||||
],
|
||||
};
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Survey, SurveyStatus, SurveyType } from "@prisma/client";
|
||||
import { SurveyStatus, SurveyType } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
// eslint-disable-next-line import/no-relative-packages -- Need to import from parent package
|
||||
@@ -92,10 +92,10 @@ const ZSurveyBase = z.object({
|
||||
}),
|
||||
questions: z.array(ZSurveyQuestion).openapi({
|
||||
description: "The questions of the survey",
|
||||
}) as z.ZodType<Survey["questions"]>,
|
||||
}),
|
||||
endings: z.array(ZSurveyEnding).default([]).openapi({
|
||||
description: "The endings of the survey",
|
||||
}) as z.ZodType<Survey["endings"]>,
|
||||
}),
|
||||
thankYouCard: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
@@ -115,7 +115,7 @@ const ZSurveyBase = z.object({
|
||||
}),
|
||||
variables: z.array(ZSurveyVariable).openapi({
|
||||
description: "Survey variables",
|
||||
}) as z.ZodType<Survey["variables"]>,
|
||||
}),
|
||||
displayOption: z.enum(["displayOnce", "displayMultiple", "displaySome", "respondMultiple"]).openapi({
|
||||
description: "Display options for the survey",
|
||||
}),
|
||||
@@ -219,10 +219,10 @@ const ZSurveyBase = z.object({
|
||||
}),
|
||||
displayPercentage: z.number().nullable().openapi({
|
||||
description: "The display percentage of the survey",
|
||||
}) as z.ZodType<Survey["displayPercentage"]>,
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZSurvey = ZSurveyBase satisfies z.ZodType<Survey>;
|
||||
export const ZSurvey = ZSurveyBase;
|
||||
|
||||
export const ZSurveyWithoutQuestionType = ZSurveyBase.omit({
|
||||
questions: true,
|
||||
|
||||
@@ -14,7 +14,7 @@ export function Headline({ headline, questionId, required = true, alignTextCente
|
||||
<div
|
||||
className={`fb-flex fb-items-center ${alignTextCenter ? "fb-justify-center" : "fb-justify-between"}`}
|
||||
dir="auto">
|
||||
{headline}
|
||||
<p>{headline}</p>
|
||||
{!required && (
|
||||
<span
|
||||
className="fb-text-heading fb-mx-2 fb-self-start fb-text-sm fb-font-normal fb-leading-7 fb-opacity-60"
|
||||
|
||||
@@ -7,6 +7,9 @@ describe("i18n", () => {
|
||||
test("should return empty string for undefined value", () => {
|
||||
expect(getLocalizedValue(undefined, "en")).toBe("");
|
||||
});
|
||||
test("should return empty string for empty string", () => {
|
||||
expect(getLocalizedValue({ default: "" }, "en")).toBe("");
|
||||
});
|
||||
|
||||
test("should return empty string for non-i18n string", () => {
|
||||
expect(getLocalizedValue("not an i18n string" as any, "en")).toBe("");
|
||||
|
||||
@@ -10,7 +10,7 @@ export const getLocalizedValue = (value: TI18nString | undefined, languageId: st
|
||||
return "";
|
||||
}
|
||||
if (isI18nObject(value)) {
|
||||
if (value[languageId]) {
|
||||
if (typeof value[languageId] === "string") {
|
||||
return value[languageId];
|
||||
}
|
||||
return value.default;
|
||||
|
||||
@@ -297,7 +297,7 @@ export const ZResponseInput = z.object({
|
||||
environmentId: z.string().cuid2(),
|
||||
surveyId: z.string().cuid2(),
|
||||
userId: z.string().nullish(),
|
||||
displayId: z.string().nullish(),
|
||||
displayId: z.string().cuid2().nullish(),
|
||||
singleUseId: z.string().nullable().optional(),
|
||||
finished: z.boolean(),
|
||||
endingId: z.string().nullish(),
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -645,6 +645,9 @@ importers:
|
||||
dotenv-cli:
|
||||
specifier: 8.0.0
|
||||
version: 8.0.0
|
||||
glob:
|
||||
specifier: 11.0.2
|
||||
version: 11.0.2
|
||||
prisma:
|
||||
specifier: 6.7.0
|
||||
version: 6.7.0(typescript@5.8.3)
|
||||
@@ -654,6 +657,12 @@ importers:
|
||||
ts-node:
|
||||
specifier: 10.9.2
|
||||
version: 10.9.2(@types/node@22.15.18)(typescript@5.8.3)
|
||||
vite:
|
||||
specifier: 6.3.5
|
||||
version: 6.3.5(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
vite-plugin-dts:
|
||||
specifier: 4.5.3
|
||||
version: 4.5.3(@types/node@22.15.18)(rollup@4.40.2)(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.18)(jiti@2.4.2)(terser@5.39.1)(tsx@4.19.4)(yaml@2.8.0))
|
||||
|
||||
packages/i18n-utils:
|
||||
devDependencies:
|
||||
|
||||
@@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# Coverage
|
||||
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**
|
||||
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**
|
||||
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**
|
||||
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,apps/web/modules/analysis/components/SingleResponseCard/components/Smileys.tsx,packages/surveys/src/components/general/smileys.tsx,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/lib/shortUrl/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/posthogServer.ts,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**
|
||||
|
||||
12
turbo.json
12
turbo.json
@@ -6,7 +6,7 @@
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"@formbricks/database#lint": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#build"]
|
||||
},
|
||||
"@formbricks/database#setup": {
|
||||
"dependsOn": ["db:up"]
|
||||
@@ -30,6 +30,9 @@
|
||||
"dependsOn": ["@formbricks/database#db:setup"],
|
||||
"persistent": true
|
||||
},
|
||||
"@formbricks/js-core#lint": {
|
||||
"dependsOn": ["@formbricks/database#build"]
|
||||
},
|
||||
"@formbricks/react-native#build": {
|
||||
"dependsOn": ["^build", "@formbricks/database#build"],
|
||||
"outputs": ["dist/**"]
|
||||
@@ -58,10 +61,10 @@
|
||||
"persistent": true
|
||||
},
|
||||
"@formbricks/web#test": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#build"]
|
||||
},
|
||||
"@formbricks/web#test:coverage": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#build"]
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
@@ -143,6 +146,7 @@
|
||||
"SAML_DATABASE_URL",
|
||||
"SESSION_MAX_AGE",
|
||||
"SENTRY_DSN",
|
||||
"SENTRY_ENVIRONMENT",
|
||||
"SLACK_CLIENT_ID",
|
||||
"SLACK_CLIENT_SECRET",
|
||||
"SMTP_HOST",
|
||||
@@ -205,7 +209,7 @@
|
||||
},
|
||||
"db:setup": {
|
||||
"cache": false,
|
||||
"dependsOn": ["@formbricks/logger#build"],
|
||||
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#build"],
|
||||
"outputs": []
|
||||
},
|
||||
"db:start": {
|
||||
|
||||
Reference in New Issue
Block a user