Compare commits

...

49 Commits

Author SHA1 Message Date
pandeymangg
249e42b087 Merge branch 'main' into hmaan-displayId 2025-07-04 14:19:05 +05:30
Piyush Gupta
ba9b01a969 fix: survey list refresh (#6104)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-04 08:16:27 +00:00
Harsh Bhat
e810e38333 chore: change pricing (#5850)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-03 13:40:19 +00:00
victorvhs017
dab8ad00d5 feat: Add Sentry source maps (#6047) 2025-07-03 13:03:59 +00:00
pandeymangg
9ec4f22b1f Merge remote-tracking branch 'origin/main' into hmaan-displayId 2025-07-03 13:26:52 +05:30
Harsh-deepsingh
ea00dec8e5 fix: rename function as requested 2025-07-02 01:21:34 -06:00
Anshuman Pandey
2c34f43c83 fix: adds build step to the database package for optimizing docker build (#5970)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-02 03:42:01 +00:00
Kunal Garg
979fd71a11 feat: reset password in accounts page (#5219)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-01 15:41:14 +00:00
Harsh Bhat
1be23eebbb docs: Add audit logs, domain split in the license details (#6139) 2025-07-01 04:57:42 -07:00
Dhruwang Jariwala
d10cff917d fix: recall parsing for headlines with empty strings (#6131) 2025-07-01 08:16:14 +00:00
Dhruwang Jariwala
da72101320 fix: active tab scaling issue (#6127) 2025-06-30 11:10:33 +00:00
Aditya
5f02ad49c1 fix: allow dynamic height for action cards to show full text (#6106)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-30 02:29:06 -07:00
Dhruwang Jariwala
6644bba6ea fix: formatted databse error message for response endpoint (#6111) 2025-06-30 06:15:50 +00:00
Piyush Gupta
0b7734f725 fix: optional fields in update response API (#6113) 2025-06-30 06:13:42 +00:00
Harsh-deepsingh
70cd751b0e displayId validation as cuid2 if present 2025-06-29 17:50:20 -06:00
Harsh-deepsingh
4308d86bbc fix: add validation for displayId in client response APIs 2025-06-29 17:03:36 -06:00
Dhruwang Jariwala
1536bf6907 fix: question change issue (#6091) 2025-06-29 11:10:30 -07:00
Varun Singh
e81190214f feat: Enable recall for welcome cards. (#5963)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-06-29 10:24:54 -07:00
Romit
48c8906a89 fix: Preview in Email embed is broken (#6120) 2025-06-29 09:31:26 -07:00
Johannes
717b30115b fix: align settings card height plus border radius (#6119) 2025-06-27 07:20:52 -07:00
victorvhs017
1f3962d2d5 fix: updated url validation (#6096)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-06-27 13:01:36 +00:00
Piyush Gupta
619f6e408f fix: /api/v2/management/contact-attribute-keys returns 500 instead of 409 on duplicate record (#6100) 2025-06-27 12:50:35 +00:00
Dhruwang Jariwala
4a8719abaa fix: auto subscribe (#6114) 2025-06-27 12:33:08 +00:00
Dhruwang Jariwala
7b59eb3b26 fix: name and description updation in contact attribute key via api (#6089) 2025-06-27 12:09:41 +00:00
Piyush Gupta
8ac280268d fix: update preview URL construction in survey dropdown menu (#6117) 2025-06-27 11:42:14 +00:00
Dhruwang Jariwala
34e8f4931d chore: simplified sharing modal access (#6103) 2025-06-27 11:39:15 +00:00
Piyush Gupta
ac46850a24 fix: unformatted db errors in contact attribute keys management v1 API (#6102) 2025-06-27 05:48:08 +00:00
victorvhs017
6328be220a fix: updated api docs to use - instead of > (#6107) 2025-06-26 09:54:34 -07:00
Dhruwang Jariwala
882ad99ed7 fix: templates page back button (#6088)
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-26 10:38:45 +00:00
Piyush Gupta
ce47b4c2d8 fix: improper zod validation in action classes management API (#6084) 2025-06-26 10:21:01 +00:00
Matti Nannt
ce8f9de8ec fix: confetti animation display issue (#6085)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-26 06:35:19 +00:00
Anshuman Pandey
ed3c2d2b58 fix: fixes shrinking checkbox (#6092) 2025-06-26 05:14:54 +00:00
Anshuman Pandey
9ae226329b fix: decreases environment ttl to 5 minutes (#6087) 2025-06-25 10:30:36 +00:00
Piyush Gupta
12c3899b85 fix: input validation in management v2 webhooks API (#6078) 2025-06-25 09:49:56 +00:00
Piyush Gupta
ccb1353eb5 fix: split domain docs (#6086) 2025-06-25 00:50:23 -07:00
Johannes
22eb0b79ee chore: update issue templates (#6081) 2025-06-24 13:42:10 -07:00
Abhishek Sharma
5eb7a496da fix: "Add ending" button ui distortion in safari browser (#6048) 2025-06-24 11:50:17 -07:00
Matti Nannt
7ea55e199f chore(infra): always pull new images on staging (#6079) 2025-06-24 19:45:00 +02:00
Varun Singh
83eb472acd fix: Empty survey list state after deleting the last survey. (#6044)
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-24 07:52:18 -07:00
Jakob Schott
d9fe6ee4f4 fix: styling update and loading animation for survey media (#6020) 2025-06-24 09:53:27 +00:00
Anshuman Pandey
51b58be079 docs: fixes the bulk contact upload api docs and adds the email property (#6066)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-06-24 01:44:34 -07:00
Harsh Bhat
397643330a docs: Update docs for Private file upload and general client API (#6045) 2025-06-23 08:26:10 -07:00
Piyush Gupta
e5fa4328e1 fix: tls handshake failure in self-hosting license generation (#6050) 2025-06-23 08:42:08 +00:00
Jakob Schott
4b777f1907 feat: unify modal component in storybook (#5901) 2025-06-22 13:54:04 +00:00
Piyush Gupta
c3547ccb36 fix: default environment redirect (#6033) 2025-06-20 16:46:43 +00:00
Johannes
a0f334b300 chore: add rules (#6036) 2025-06-19 09:02:25 -07:00
Jakob Schott
a9f635b768 chore: Satisfy SonarQube ReadOnly props for all question types (#6021) 2025-06-19 06:10:11 +00:00
Jakob Schott
d385b4a0d6 fix: Set non-required as default value on questions (#6018) 2025-06-19 06:09:36 +00:00
Matti Nannt
5e825413d2 chore(infra): switch staging to internal lb (#6012) 2025-06-18 12:04:53 +00:00
246 changed files with 4420 additions and 2053 deletions

View File

@@ -0,0 +1,216 @@
---
description:
globs:
alwaysApply: false
---
# Component Migration Automation Rule
## Overview
This rule automates the migration of deprecated components to new component systems in React/TypeScript codebases.
## Trigger
When the user requests component migration (e.g., "migrate [DeprecatedComponent] to [NewComponent]" or "component migration").
## Process
### Step 1: Discovery and Planning
1. **Identify migration parameters:**
- Ask user for deprecated component name (e.g., "Modal")
- Ask user for new component name(s) (e.g., "Dialog")
- Ask for any components to exclude (e.g., "ModalWithTabs")
- Ask for specific import paths if needed
2. **Scan codebase** for deprecated components:
- Search for `import.*[DeprecatedComponent]` patterns
- Exclude specified components that should not be migrated
- List all found components with file paths
- Present numbered list to user for confirmation
### Step 2: Component-by-Component Migration
For each component, follow this exact sequence:
#### 2.1 Component Migration
- **Import changes:**
- Ask user to provide the new import structure
- Example transformation pattern:
```typescript
// FROM:
import { [DeprecatedComponent] } from "@/components/ui/[DeprecatedComponent]"
// TO:
import {
[NewComponent],
[NewComponentPart1],
[NewComponentPart2],
// ... other parts
} from "@/components/ui/[NewComponent]"
```
- **Props transformation:**
- Ask user for prop mapping rules (e.g., `open` → `open`, `setOpen` → `onOpenChange`)
- Ask for props to remove (e.g., `noPadding`, `closeOnOutsideClick`, `size`)
- Apply transformations based on user specifications
- **Structure transformation:**
- Ask user for the new component structure pattern
- Apply the transformation maintaining all functionality
- Preserve all existing logic, state management, and event handlers
#### 2.2 Wait for User Approval
- Present the migration changes
- Wait for explicit user approval before proceeding
- If rejected, ask for specific feedback and iterate
#### 2.3 Re-read and Apply Additional Changes
- Re-read the component file to capture any user modifications
- Apply any additional improvements the user made
- Ensure all changes are incorporated
#### 2.4 Test File Updates
- **Find corresponding test file** (same name with `.test.tsx` or `.test.ts`)
- **Update test mocks:**
- Ask user for new component mock structure
- Replace old component mocks with new ones
- Example pattern:
```typescript
// Add to test setup:
jest.mock("@/components/ui/[NewComponent]", () => ({
[NewComponent]: ({ children, [props] }: any) => ([mock implementation]),
[NewComponentPart1]: ({ children }: any) => <div data-testid="[new-component-part1]">{children}</div>,
[NewComponentPart2]: ({ children }: any) => <div data-testid="[new-component-part2]">{children}</div>,
// ... other parts
}));
```
- **Update test expectations:**
- Change test IDs from old component to new component
- Update any component-specific assertions
- Ensure all new component parts used in the component are mocked
#### 2.5 Run Tests and Optimize
- Execute `Node package manager test -- ComponentName.test.tsx`
- Fix any failing tests
- Optimize code quality (imports, formatting, etc.)
- Re-run tests until all pass
- **Maximum 3 iterations** - if still failing, ask user for guidance
#### 2.6 Wait for Final Approval
- Present test results and any optimizations made
- Wait for user approval of the complete migration
- If rejected, iterate based on feedback
#### 2.7 Git Commit
- Run: `git add .`
- Run: `git commit -m "migrate [ComponentName] from [DeprecatedComponent] to [NewComponent]"`
- Confirm commit was successful
### Step 3: Final Report Generation
After all components are migrated, generate a comprehensive GitHub PR report:
#### PR Title
```
feat: migrate [DeprecatedComponent] components to [NewComponent] system
```
#### PR Description Template
```markdown
## 🔄 [DeprecatedComponent] to [NewComponent] Migration
### Overview
Migrated [X] [DeprecatedComponent] components to the new [NewComponent] component system to modernize the UI architecture and improve consistency.
### Components Migrated
[List each component with file path]
### Technical Changes
- **Imports:** Replaced `[DeprecatedComponent]` with `[NewComponent], [NewComponentParts...]`
- **Props:** [List prop transformations]
- **Structure:** Implemented proper [NewComponent] component hierarchy
- **Styling:** [Describe styling changes]
- **Tests:** Updated all test mocks and expectations
### Migration Pattern
```typescript
// Before
<[DeprecatedComponent] [oldProps]>
[oldStructure]
</[DeprecatedComponent]>
// After
<[NewComponent] [newProps]>
[newStructure]
</[NewComponent]>
```
### Testing
- ✅ All existing tests updated and passing
- ✅ Component functionality preserved
- ✅ UI/UX behavior maintained
### How to Test This PR
1. **Functional Testing:**
- Navigate to each migrated component's usage
- Verify [component] opens and closes correctly
- Test all interactive elements within [components]
- Confirm styling and layout are preserved
2. **Automated Testing:**
```bash
Node package manager test
```
3. **Visual Testing:**
- Check that all [components] maintain proper styling
- Verify responsive behavior
- Test keyboard navigation and accessibility
### Breaking Changes
[List any breaking changes or state "None - this is a drop-in replacement maintaining all existing functionality."]
### Notes
- [Any excluded components] were preserved as they already use [NewComponent] internally
- All form validation and complex state management preserved
- Enhanced code quality with better imports and formatting
```
## Special Considerations
### Excluded Components
- **DO NOT MIGRATE** components specified by user as exclusions
- They may already use the new component internally or have other reasons
- Inform user these are skipped and why
### Complex Components
- Preserve all existing functionality (forms, validation, state management)
- Maintain prop interfaces
- Keep all event handlers and callbacks
- Preserve accessibility features
### Test Coverage
- Ensure all new component parts are mocked when used
- Mock all new component parts that appear in the component
- Update test IDs from old component to new component
- Maintain all existing test scenarios
### Error Handling
- If tests fail after 3 iterations, stop and ask user for guidance
- If component is too complex, ask user for specific guidance
- If unsure about functionality preservation, ask for clarification
### Migration Patterns
- Always ask user for specific migration patterns before starting
- Confirm import structures, prop mappings, and component hierarchies
- Adapt to different component architectures (simple replacements, complex restructuring, etc.)
## Success Criteria
- All deprecated components successfully migrated to new components
- All tests passing
- No functionality lost
- Code quality maintained or improved
- User approval on each component
- Successful git commits for each migration
- Comprehensive PR report generated
## Usage Examples
- "migrate Modal to Dialog"
- "migrate Button to NewButton"
- "migrate Card to ModernCard"
- "component migration" (will prompt for details)

View File

@@ -0,0 +1,23 @@
---
description: Guideline for writing end-user facing documentation in the apps/docs folder
globs:
alwaysApply: false
---
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
---
title: "FEATURE NAME"
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
icon: "link"
---
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
- If a feature is part of the Enterprise Edition, use this note:
<Note>
FEATURE NAME is part of the @Enterprise Edition.
</Note>

View File

@@ -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"

View File

@@ -1,6 +1,7 @@
name: Feature request
description: "Suggest an idea for this project \U0001F680"
type: feature
projects: "formbricks/21"
body:
- type: textarea
id: problem-description

View File

@@ -1,11 +0,0 @@
name: Task (internal)
description: "Template for creating a task. Used by the Formbricks Team only \U0001f4e5"
type: task
body:
- type: textarea
id: task-summary
attributes:
label: Task description
description: A clear detailed-rich description of the task.
validations:
required: true

View 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"

View File

@@ -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 }}

View 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 }}

View File

@@ -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"]

View File

@@ -94,6 +94,7 @@ describe("LandingSidebar component", () => {
organizationId: "o1",
redirect: true,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
});
});

View File

@@ -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} />}>

View File

@@ -11,22 +11,21 @@ export const ActionClassDataRow = ({
locale: TUserLocale;
}) => {
return (
<div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
<div className="m-2 grid grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-start py-3 pl-6 text-sm">
<div className="flex w-full items-center gap-4">
<div className="mt-1 h-5 w-5 flex-shrink-0 text-slate-500">
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
</div>
<div className="ml-4 text-left">
<div className="font-medium text-slate-900">{actionClass.name}</div>
<div className="text-xs text-slate-400">{actionClass.description}</div>
<div className="text-left">
<div className="break-words font-medium text-slate-900">{actionClass.name}</div>
<div className="break-words text-xs text-slate-400">{actionClass.description}</div>
</div>
</div>
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(actionClass.createdAt.toString(), locale)}
</div>
<div className="text-center"></div>
</div>
);
};

View File

@@ -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 () => {

View File

@@ -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
}}

View File

@@ -20,7 +20,7 @@ vi.mock("@/modules/ui/components/switch", () => ({
}));
vi.mock("../actions", () => ({
updateNotificationSettingsAction: vi.fn(() => Promise.resolve()),
updateNotificationSettingsAction: vi.fn(() => Promise.resolve({ data: true })),
}));
const surveyId = "survey1";
@@ -246,4 +246,204 @@ describe("NotificationSwitch", () => {
});
expect(updateNotificationSettingsAction).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'alert' type", async () => {
const mockErrorResponse = { serverError: "Failed to update notification settings" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("Failed to update notification settings", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'weeklySummary' type", async () => {
const mockErrorResponse = { serverError: "Database connection failed" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
renderSwitch({
surveyOrProjectOrOrganizationId: projectId,
notificationSettings: initialSettings,
notificationType: "weeklySummary",
});
const switchInput = screen.getByLabelText("toggle notification settings for weeklySummary");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, weeklySummary: { [projectId]: false } },
});
expect(toast.error).toHaveBeenCalledWith("Database connection failed", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
const mockErrorResponse = { serverError: "Permission denied" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
renderSwitch({
surveyOrProjectOrOrganizationId: organizationId,
notificationSettings: initialSettings,
notificationType: "unsubscribedOrganizationIds",
});
const switchInput = screen.getByLabelText("toggle notification settings for unsubscribedOrganizationIds");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, unsubscribedOrganizationIds: [organizationId] },
});
expect(toast.error).toHaveBeenCalledWith("Permission denied", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction returns null", async () => {
const mockErrorResponse = { serverError: "An error occurred" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction returns undefined", async () => {
const mockErrorResponse = { serverError: "An error occurred" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("An error occurred", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction returns response without data property", async () => {
const mockErrorResponse = { validationErrors: { _errors: ["Invalid input"] } };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("Invalid input", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("shows error toast when updateNotificationSettingsAction throws an exception", async () => {
const mockErrorResponse = { serverError: "Network error" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("Network error", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
test("switch remains enabled after error occurs", async () => {
const mockErrorResponse = { serverError: "Failed to update" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(toast.error).toHaveBeenCalledWith("Failed to update", {
id: "notification-switch",
});
expect(switchInput).toBeEnabled(); // Switch should be re-enabled after error
});
test("shows error toast with validation errors for specific fields", async () => {
const mockErrorResponse = {
validationErrors: {
notificationSettings: {
_errors: ["Invalid notification settings"],
},
},
};
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
const initialSettings = { ...baseNotificationSettings, alert: { [surveyId]: false } };
renderSwitch({ notificationSettings: initialSettings, notificationType: "alert" });
const switchInput = screen.getByLabelText("toggle notification settings for alert");
await act(async () => {
await user.click(switchInput);
});
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
notificationSettings: { ...initialSettings, alert: { [surveyId]: true } },
});
expect(toast.error).toHaveBeenCalledWith("notificationSettingsInvalid notification settings", {
id: "notification-switch",
});
expect(toast.success).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,9 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Switch } from "@/modules/ui/components/switch";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TUserNotificationSettings } from "@formbricks/types/user";
@@ -24,6 +26,7 @@ export const NotificationSwitch = ({
}: NotificationSwitchProps) => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslate();
const router = useRouter();
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
@@ -50,7 +53,20 @@ export const NotificationSwitch = ({
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
}
await updateNotificationSettingsAction({ notificationSettings: updatedNotificationSettings });
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
notificationSettings: updatedNotificationSettings,
});
if (updatedNotificationSettingsActionResponse?.data) {
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
id: "notification-switch",
});
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updatedNotificationSettingsActionResponse);
toast.error(errorMessage, {
id: "notification-switch",
});
}
setIsLoading(false);
};
@@ -104,9 +120,6 @@ export const NotificationSwitch = ({
disabled={isLoading}
onCheckedChange={async () => {
await handleSwitchChange();
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
id: "notification-switch",
});
}}
/>
);

View File

@@ -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 };
}
)
);

View File

@@ -3,7 +3,7 @@ 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 = {
@@ -24,6 +24,8 @@ const mockUser = {
objective: "other",
} as unknown as TUser;
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
// Mock window.location.reload
const originalLocation = window.location;
beforeEach(() => {
@@ -35,6 +37,11 @@ beforeEach(() => {
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
updateUserAction: vi.fn(),
resetPasswordAction: vi.fn(),
}));
vi.mock("@/modules/auth/forgot-password/actions", () => ({
forgotPasswordAction: vi.fn(),
}));
afterEach(() => {
@@ -50,7 +57,13 @@ describe("EditProfileDetailsForm", () => {
test("renders with initial user data and updates successfully", async () => {
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />);
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={true}
isPasswordResetEnabled={false}
/>
);
const nameInput = screen.getByPlaceholderText("common.full_name");
expect(nameInput).toHaveValue(mockUser.name);
@@ -91,7 +104,13 @@ describe("EditProfileDetailsForm", () => {
const errorMessage = "Update failed";
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={false}
/>
);
const nameInput = screen.getByPlaceholderText("common.full_name");
await userEvent.clear(nameInput);
@@ -109,7 +128,13 @@ describe("EditProfileDetailsForm", () => {
});
test("update button is disabled initially and enables on change", async () => {
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={false}
/>
);
const updateButton = screen.getByText("common.update");
expect(updateButton).toBeDisabled();
@@ -117,4 +142,68 @@ describe("EditProfileDetailsForm", () => {
await userEvent.type(nameInput, " updated");
expect(updateButton).toBeEnabled();
});
test("reset password button works", async () => {
vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } });
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={true}
/>
);
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
await userEvent.click(resetButton);
await waitFor(() => {
expect(resetPasswordAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading");
});
});
test("reset password button handles error correctly", async () => {
const errorMessage = "Reset failed";
vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage });
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={true}
/>
);
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
await userEvent.click(resetButton);
await waitFor(() => {
expect(resetPasswordAction).toHaveBeenCalled();
});
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(errorMessage);
});
});
test("reset password button shows loading state", async () => {
vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
render(
<EditProfileDetailsForm
user={mockUser}
emailVerificationDisabled={false}
isPasswordResetEnabled={true}
/>
);
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
await userEvent.click(resetButton);
expect(resetButton).toBeDisabled();
});
});

View File

@@ -14,6 +14,7 @@ import {
} from "@/modules/ui/components/dropdown-menu";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
@@ -22,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({
@@ -30,13 +31,17 @@ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email:
});
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
interface IEditProfileDetailsFormProps {
user: TUser;
isPasswordResetEnabled?: boolean;
emailVerificationDisabled: boolean;
}
export const EditProfileDetailsForm = ({
user,
isPasswordResetEnabled,
emailVerificationDisabled,
}: {
user: TUser;
emailVerificationDisabled: boolean;
}) => {
}: IEditProfileDetailsFormProps) => {
const { t } = useTranslate();
const form = useForm<TEditProfileNameForm>({
@@ -50,6 +55,8 @@ export const EditProfileDetailsForm = ({
});
const { isSubmitting, isDirty } = form.formState;
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [showModal, setShowModal] = useState(false);
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
@@ -90,6 +97,7 @@ export const EditProfileDetailsForm = ({
redirectUrl: "/email-change-without-verification-success",
redirect: true,
callbackUrl: "/email-change-without-verification-success",
clearEnvironmentId: true,
});
return;
}
@@ -121,6 +129,28 @@ export const EditProfileDetailsForm = ({
}
};
const handleResetPassword = async () => {
setIsResettingPassword(true);
const result = await resetPasswordAction();
if (result?.data) {
toast.success(t("auth.forgot-password.email-sent.heading"));
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);
};
return (
<>
<FormProvider {...form}>
@@ -205,6 +235,26 @@ export const EditProfileDetailsForm = ({
)}
/>
{isPasswordResetEnabled && (
<div className="mt-4 space-y-2">
<Label htmlFor="reset-password">{t("auth.forgot-password.reset_password")}</Label>
<p className="mt-1 text-sm text-slate-500">
{t("auth.forgot-password.reset_password_description")}
</p>
<div className="flex items-center justify-between gap-2">
<Input type="email" id="reset-password" defaultValue={user.email} disabled />
<Button
onClick={handleResetPassword}
loading={isResettingPassword}
disabled={isResettingPassword}
size="default"
variant="secondary">
{t("auth.forgot-password.reset_password")}
</Button>
</div>
</div>
)}
<Button
type="submit"
className="mt-4"

View File

@@ -12,7 +12,8 @@ import Page from "./page";
// Mock services and utils
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
IS_FORMBRICKS_CLOUD: 1,
PASSWORD_RESET_DISABLED: 1,
EMAIL_VERIFICATION_DISABLED: true,
}));
vi.mock("@/lib/organization/service", () => ({

View File

@@ -1,6 +1,6 @@
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -32,6 +32,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
throw new Error(t("common.user_not_found"));
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.account_settings")}>
@@ -42,7 +44,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
<SettingsCard
title={t("environments.settings.profile.personal_information")}
description={t("environments.settings.profile.update_personal_info")}>
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
<EditProfileDetailsForm
user={user}
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
isPasswordResetEnabled={isPasswordResetEnabled}
/>
</SettingsCard>
<SettingsCard
title={t("common.avatar")}

View File

@@ -176,8 +176,8 @@ describe("ShareEmbedSurvey", () => {
));
});
test("renders initial 'start' view correctly when open and modalView is 'start'", () => {
render(<ShareEmbedSurvey {...defaultProps} />);
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} />);
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
@@ -188,6 +188,18 @@ describe("ShareEmbedSurvey", () => {
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("renders initial 'start' view correctly when open and modalView is 'start' for app survey", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} />);
// For app surveys, ShareSurveyLink should not be rendered
expect(screen.queryByText("ShareSurveyLinkMock")).not.toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />);
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
@@ -205,7 +217,7 @@ describe("ShareEmbedSurvey", () => {
});
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
@@ -219,7 +231,7 @@ describe("ShareEmbedSurvey", () => {
});
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
@@ -257,8 +269,8 @@ describe("ShareEmbedSurvey", () => {
};
expect(embedViewProps.tabs.length).toBe(3);
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
expect(embedViewProps.tabs[0].id).toBe("email");
expect(embedViewProps.activeId).toBe("email");
expect(embedViewProps.tabs[0].id).toBe("link");
expect(embedViewProps.activeId).toBe("link");
});
test("correctly configures for 'web' survey type in embed view", () => {

View File

@@ -47,13 +47,14 @@ export const ShareEmbedSurvey = ({
const tabs = useMemo(
() =>
[
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
{
id: "link",
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
icon: LinkIcon,
},
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
{ id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon },
].filter((tab) => !(survey.type === "link" && tab.id === "app")),
[t, isSingleUseLinkSurvey, survey.type]
@@ -106,27 +107,28 @@ export const ShareEmbedSurvey = ({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTitle className="sr-only" />
<DialogContent className="w-full max-w-xl bg-white p-0 md:max-w-3xl lg:h-[700px] lg:max-w-5xl">
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
{showView === "start" ? (
<div className="h-full max-w-full overflow-hidden">
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">
<DialogTitle>
<p className="pt-2 text-xl font-semibold text-slate-800">
{t("environments.surveys.summary.your_survey_is_public")} 🎉
</p>
</DialogTitle>
<DialogDescription className="hidden" />
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
</div>
<div className="flex h-[300px] flex-col items-center justify-center gap-8 rounded-b-lg bg-slate-50 px-8 lg:h-3/5">
<p className="-mt-8 text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
<div className="flex h-full max-w-full flex-col overflow-hidden">
{survey.type === "link" && (
<div className="flex h-2/5 w-full flex-col items-center justify-center space-y-6 p-8 text-center">
<DialogTitle>
<p className="pt-2 text-xl font-semibold text-slate-800">
{t("environments.surveys.summary.your_survey_is_public")} 🎉
</p>
</DialogTitle>
<DialogDescription className="hidden" />
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
</div>
)}
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-b-lg bg-slate-50 px-8">
<p className="text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
<div className="grid grid-cols-4 gap-2">
<button
type="button"

View File

@@ -1,5 +1,6 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
@@ -117,13 +118,13 @@ export const SummaryMetadata = ({
)}
</span>
{!isLoading && (
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
<Button variant="secondary" className="h-6 w-6">
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</span>
</Button>
)}
</div>
</div>

View File

@@ -69,13 +69,13 @@ vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
const mockSearchParams = new URLSearchParams();
const mockPush = vi.fn();
const mockReplace = vi.fn();
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
useRouter: () => ({ push: mockPush, replace: mockReplace }),
useSearchParams: () => mockSearchParams,
usePathname: () => "/current",
useParams: () => ({ environmentId: "env123", surveyId: "survey123" }),
usePathname: () => "/current-path",
}));
// Mock copySurveyLink to return a predictable string
@@ -131,280 +131,281 @@ const dummySurvey = {
id: "survey123",
type: "link",
environmentId: "env123",
status: "active",
status: "inProgress",
resultShareKey: null,
} as unknown as TSurvey;
const dummyAppSurvey = {
id: "survey123",
type: "app",
environmentId: "env123",
status: "inProgress",
} as unknown as TSurvey;
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
const dummyUser = { id: "user123", name: "Test User" } as TUser;
describe("SurveyAnalysisCTA - handleCopyLink", () => {
afterEach(() => {
cleanup();
});
test("calls copySurveyLink and clipboard.writeText on success", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
fireEvent.click(copyButton);
await waitFor(() => {
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
expect(writeTextMock).toHaveBeenCalledWith("https://public-domain.com/s/survey123?suId=newSingleUseId");
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
test("shows error toast on failure", async () => {
refreshSingleUseIdSpy.mockImplementationOnce(() => Promise.reject(new Error("fail")));
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const copyButton = screen.getByRole("button", { name: "common.copy_link" });
fireEvent.click(copyButton);
await waitFor(() => {
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
expect(writeTextMock).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_copy_link");
});
});
});
// New tests for squarePenIcon and edit functionality
describe("SurveyAnalysisCTA - Edit functionality", () => {
describe("SurveyAnalysisCTA", () => {
beforeEach(() => {
vi.resetAllMocks();
mockSearchParams.delete("share"); // reset params
});
afterEach(() => {
cleanup();
});
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
describe("Edit functionality", () => {
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Check if dialog is shown
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
expect(dialogTitle).toBeInTheDocument();
// Check if dialog is shown
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
expect(dialogTitle).toBeInTheDocument();
});
test("navigates directly to edit page when response count = 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={0}
/>
);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Should navigate directly to edit page
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
});
test("doesn't show edit button when isReadOnly is true", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const editButton = screen.queryByRole("button", { name: "common.edit" });
expect(editButton).not.toBeInTheDocument();
});
});
test("navigates directly to edit page when response count = 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={0}
/>
);
describe("Duplicate functionality", () => {
test("duplicates survey and redirects on primary button click", async () => {
mockCopySurveyToOtherEnvironmentAction.mockResolvedValue({
data: { id: "newSurvey456" },
});
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Should navigate directly to edit page
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
const editButton = screen.getByRole("button", { name: "common.edit" });
fireEvent.click(editButton);
const primaryButton = await screen.findByText("environments.surveys.edit.caution_edit_duplicate");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
environmentId: "env123",
surveyId: "survey123",
targetEnvironmentId: "env123",
});
expect(mockPush).toHaveBeenCalledWith("/environments/env123/surveys/newSurvey456/edit");
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
});
});
test("shows error toast on duplication failure", async () => {
const error = { error: "Duplication failed" };
mockCopySurveyToOtherEnvironmentAction.mockResolvedValue(error);
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
const editButton = screen.getByRole("button", { name: "common.edit" });
fireEvent.click(editButton);
const primaryButton = await screen.findByText("environments.surveys.edit.caution_edit_duplicate");
fireEvent.click(primaryButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Duplication failed");
});
});
});
test("doesn't show edit button when isReadOnly is true", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
describe("Share button and modal", () => {
test("opens share modal when 'Share survey' button is clicked", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Try to find the edit button (it shouldn't exist)
const editButton = screen.queryByRole("button", { name: "common.edit" });
expect(editButton).not.toBeInTheDocument();
});
});
const shareButton = screen.getByText("environments.surveys.summary.share_survey");
fireEvent.click(shareButton);
// Updated test description to mention EditPublicSurveyAlertDialog
describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertDialog", () => {
afterEach(() => {
cleanup();
// The share button opens the embed modal, not a URL
// We can verify this by checking that the ShareEmbedSurvey component is rendered
// with the embed modal open
expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument();
});
test("renders ShareEmbedSurvey component when share modal is open", async () => {
mockSearchParams.set("share", "true");
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Assuming ShareEmbedSurvey renders a dialog with a specific title when open
const dialog = await screen.findByRole("dialog");
expect(dialog).toBeInTheDocument();
});
});
test("duplicates survey successfully and navigates to edit page", async () => {
// Mock the API response
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
data: { id: "duplicated-survey-456" },
describe("General UI and visibility", () => {
test("shows public results badge when resultShareKey is present", () => {
const surveyWithShareKey = { ...dummySurvey, resultShareKey: "someKey" } as TSurvey;
render(
<SurveyAnalysisCTA
survey={surveyWithShareKey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.getByText("environments.surveys.summary.results_are_public")).toBeInTheDocument();
});
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
test("shows SurveyStatusDropdown for non-draft surveys", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Find and click the edit button to show dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Find and click the duplicate button in dialog
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Verify the API was called with correct parameters
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
environmentId: dummyEnvironment.id,
surveyId: dummySurvey.id,
targetEnvironmentId: dummyEnvironment.id,
expect(screen.getByRole("combobox")).toBeInTheDocument();
});
// Verify success toast was shown
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
// Verify navigation to edit page
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/duplicated-survey-456/edit`
);
});
test("shows error toast when duplication fails with error object", async () => {
// Mock API failure with error object
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
error: "Test error message",
test("does not show SurveyStatusDropdown for draft surveys", () => {
const draftSurvey = { ...dummySurvey, status: "draft" } as TSurvey;
render(
<SurveyAnalysisCTA
survey={draftSurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
test("hides status dropdown and edit actions when isReadOnly is true", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click duplicate
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Verify error toast
expect(toast.error).toHaveBeenCalledWith("Test error message");
});
test("navigates to edit page when cancel button is clicked in dialog", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click edit (cancel) button
const editButtonInDialog = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButtonInDialog);
// Verify navigation
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
});
test("shows loading state when duplicating survey", async () => {
// Create a promise that we can resolve manually
let resolvePromise: (value: any) => void;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.edit" })).not.toBeInTheDocument();
});
mockCopySurveyToOtherEnvironmentAction.mockImplementation(() => promise);
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click duplicate
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Button should now be in loading state
// expect(duplicateButton).toHaveAttribute("data-state", "loading");
// Resolve the promise
resolvePromise!({
data: { id: "duplicated-survey-456" },
test("shows preview button for link surveys", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.getByRole("button", { name: "common.preview" })).toBeInTheDocument();
});
// Wait for the promise to resolve
await waitFor(() => {
expect(mockPush).toHaveBeenCalled();
test("hides preview button for app surveys", () => {
render(
<SurveyAnalysisCTA
survey={dummyAppSurvey}
environment={dummyEnvironment}
isReadOnly={false}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
);
expect(screen.queryByRole("button", { name: "common.preview" })).not.toBeInTheDocument();
});
});
});

View File

@@ -5,13 +5,12 @@ import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
import { BellRing, Code2Icon, Eye, LinkIcon, SquarePenIcon, UsersRound } from "lucide-react";
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
@@ -57,7 +56,6 @@ export const SurveyAnalysisCTA = ({
});
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -79,22 +77,6 @@ export const SurveyAnalysisCTA = ({
setModalState((prev) => ({ ...prev, share: open }));
};
const handleCopyLink = () => {
refreshSingleUseId()
.then((newId) => {
const linkToCopy = copySurveyLink(surveyUrl, newId);
return navigator.clipboard.writeText(linkToCopy);
})
.then(() => {
toast.success(t("common.copied_to_clipboard"));
})
.catch((err) => {
toast.error(t("environments.surveys.summary.failed_to_copy_link"));
console.error(err);
});
setModalState((prev) => ({ ...prev, dropdown: false }));
};
const duplicateSurveyAndRoute = async (surveyId: string) => {
setLoading(true);
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
@@ -134,24 +116,6 @@ export const SurveyAnalysisCTA = ({
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const iconActions = [
{
icon: Eye,
tooltip: t("common.preview"),
onClick: () => window.open(getPreviewUrl(), "_blank"),
isVisible: survey.type === "link",
},
{
icon: LinkIcon,
tooltip: t("common.copy_link"),
onClick: handleCopyLink,
isVisible: survey.type === "link",
},
{
icon: Code2Icon,
tooltip: t("common.embed"),
onClick: () => handleModalState("embed")(true),
isVisible: !isReadOnly,
},
{
icon: BellRing,
tooltip: t("environments.surveys.summary.configure_alerts"),
@@ -159,13 +123,10 @@ export const SurveyAnalysisCTA = ({
isVisible: !isReadOnly,
},
{
icon: UsersRound,
tooltip: t("environments.surveys.summary.send_to_panel"),
onClick: () => {
handleModalState("panel")(true);
setModalState((prev) => ({ ...prev, dropdown: false }));
},
isVisible: !isReadOnly,
icon: Eye,
tooltip: t("common.preview"),
onClick: () => window.open(getPreviewUrl(), "_blank"),
isVisible: survey.type === "link",
},
{
icon: SquarePenIcon,
@@ -195,6 +156,13 @@ export const SurveyAnalysisCTA = ({
)}
<IconBar actions={iconActions} />
<Button
className="h-10"
onClick={() => {
setModalState((prev) => ({ ...prev, embed: true }));
}}>
{t("environments.surveys.summary.share_survey")}
</Button>
{user && (
<>

View File

@@ -274,7 +274,7 @@ describe("getEnvironmentState", () => {
expect(withCache).toHaveBeenCalledWith(expect.any(Function), {
key: `fb:env:${environmentId}:state`,
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
});
});
});

View File

@@ -83,9 +83,8 @@ export const getEnvironmentState = async (
{
// Use enterprise-grade cache key pattern
key: createCacheKey.environment.state(environmentId),
// 30 minutes TTL ensures fresh data for hourly SDK checks
// Balances performance with freshness requirements
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
// This is a temporary fix for the invalidation issues, will be changed later with a proper solution
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
}
);

View File

@@ -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"] = {

View File

@@ -52,14 +52,6 @@ export const POST = withApiLogging(
}
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
@@ -70,6 +62,14 @@ export const POST = withApiLogging(
};
}
const environmentId = inputValidation.data.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
auditLog.targetId = actionClass.id;
auditLog.newObject = actionClass;

View File

@@ -186,6 +186,18 @@ describe("Response Lib Tests", () => {
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
});
test("should handle RelatedRecordDoesNotExist error with specific message", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Related record does not exist", {
code: "P2025", // PrismaErrorType.RelatedRecordDoesNotExist
clientVersion: "2.0",
});
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
await expect(createResponse(mockResponseInput)).rejects.toThrow("Display ID does not exist");
});
test("should handle generic errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);

View File

@@ -12,6 +12,7 @@ import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
@@ -176,6 +177,9 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.RelatedRecordDoesNotExist) {
throw new DatabaseError("Display ID does not exist");
}
throw new DatabaseError(error.message);
}

View File

@@ -149,6 +149,10 @@ export const POST = withApiLogging(
return {
response: responses.badRequestResponse(error.message),
};
} else if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return {
@@ -158,7 +162,7 @@ export const POST = withApiLogging(
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
response: responses.badRequestResponse("An unexpected error occurred while creating the response"),
};
}
throw error;

View File

@@ -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"] = {

View File

@@ -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>
),

View File

@@ -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>

View File

@@ -41,7 +41,7 @@ describe("Survey Builder", () => {
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
shuffleOption: "none",
required: true,
required: false,
});
expect(question.choices.length).toBe(3);
expect(question.id).toBeDefined();
@@ -141,7 +141,7 @@ describe("Survey Builder", () => {
inputType: "text",
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
charLimit: {
enabled: false,
},
@@ -204,7 +204,7 @@ describe("Survey Builder", () => {
range: 5,
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
@@ -265,7 +265,7 @@ describe("Survey Builder", () => {
headline: { default: "NPS Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
@@ -324,7 +324,7 @@ describe("Survey Builder", () => {
label: { default: "I agree to terms" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
});
expect(question.id).toBeDefined();
});
@@ -377,7 +377,7 @@ describe("Survey Builder", () => {
headline: { default: "CTA Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: true,
required: false,
buttonExternal: false,
});
expect(question.id).toBeDefined();

View File

@@ -66,7 +66,7 @@ export const buildMultipleChoiceQuestion = ({
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
shuffleOption: shuffleOption || "none",
required: required ?? true,
required: required ?? false,
logic,
};
};
@@ -105,7 +105,7 @@ export const buildOpenTextQuestion = ({
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
required: required ?? true,
required: required ?? false,
longAnswer,
logic,
charLimit: {
@@ -153,7 +153,7 @@ export const buildRatingQuestion = ({
range,
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
required: required ?? true,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
@@ -194,7 +194,7 @@ export const buildNPSQuestion = ({
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
required: required ?? true,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
@@ -230,7 +230,7 @@ export const buildConsentQuestion = ({
headline: createI18nString(headline, []),
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
required: required ?? true,
required: required ?? false,
label: createI18nString(label, []),
logic,
};
@@ -269,7 +269,7 @@ export const buildCTAQuestion = ({
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
required: required ?? true,
required: required ?? false,
buttonExternal,
buttonUrl,
logic,

View File

@@ -8,7 +8,7 @@ import { TUser } from "@formbricks/types/user";
import Page from "./page";
vi.mock("@/lib/project/service", () => ({
getProjectEnvironmentsByOrganizationIds: vi.fn(),
getUserProjectEnvironmentsByOrganizationIds: vi.fn(),
}));
vi.mock("@/lib/instance/service", () => ({
@@ -152,7 +152,7 @@ describe("Page", () => {
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
const { redirect } = await import("next/navigation");
@@ -220,7 +220,7 @@ describe("Page", () => {
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(
mockUserProjects as unknown as TProject[]
);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
@@ -241,7 +241,7 @@ describe("Page", () => {
const { getServerSession } = await import("next-auth");
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
@@ -310,7 +310,7 @@ describe("Page", () => {
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(
mockUserProjects as unknown as TProject[]
);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
@@ -334,7 +334,7 @@ describe("Page", () => {
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { render } = await import("@testing-library/react");
const mockUser: TUser = {
@@ -432,7 +432,7 @@ describe("Page", () => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects);
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects);
vi.mocked(getAccessFlags).mockReturnValue({
isManager: false,
isOwner: false,

View File

@@ -3,7 +3,7 @@ import { getIsFreshInstance } from "@/lib/instance/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getProjectEnvironmentsByOrganizationIds } from "@/lib/project/service";
import { getUserProjectEnvironmentsByOrganizationIds } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -34,7 +34,10 @@ const Page = async () => {
return redirect("/setup/organization/create");
}
const projectsByOrg = await getProjectEnvironmentsByOrganizationIds(userOrganizations.map((org) => org.id));
const projectsByOrg = await getUserProjectEnvironmentsByOrganizationIds(
userOrganizations.map((org) => org.id),
user.id
);
// Flatten all environments from all projects across all organizations
const allEnvironments = projectsByOrg.flatMap((project) => project.environments);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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";

View File

@@ -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;
}
});

View File

@@ -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,
},
});

View File

@@ -65,7 +65,8 @@ export const validateSingleFile = (
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
};
export const validateFileUploads = (data: TResponseData, questions?: TSurveyQuestion[]): boolean => {
export const validateFileUploads = (data?: TResponseData, questions?: TSurveyQuestion[]): boolean => {
if (!data) return true;
for (const key of Object.keys(data)) {
const question = questions?.find((q) => q.id === key);
if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue;

View File

@@ -1,9 +1,16 @@
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { updateUser } from "@/lib/user/service";
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { createOrganization, getOrganization, getOrganizationsByUserId, updateOrganization } from "./service";
import {
createOrganization,
getOrganization,
getOrganizationsByUserId,
subscribeOrganizationMembersToSurveyResponses,
updateOrganization,
} from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -13,9 +20,16 @@ vi.mock("@formbricks/database", () => ({
create: vi.fn(),
update: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/user/service", () => ({
updateUser: vi.fn(),
}));
describe("Organization Service", () => {
afterEach(() => {
vi.clearAllMocks();
@@ -252,4 +266,62 @@ describe("Organization Service", () => {
});
});
});
describe("subscribeOrganizationMembersToSurveyResponses", () => {
test("should subscribe user to survey responses when not unsubscribed", async () => {
const mockUser = {
id: "user-123",
notificationSettings: {
alert: { "existing-survey-id": true },
weeklySummary: {},
unsubscribedOrganizationIds: [], // User is subscribed to all organizations
},
} as any;
const surveyId = "survey-123";
const userId = "user-123";
const organizationId = "org-123";
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
vi.mocked(updateUser).mockResolvedValueOnce({} as any);
await subscribeOrganizationMembersToSurveyResponses(surveyId, userId, organizationId);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: userId },
});
expect(updateUser).toHaveBeenCalledWith(userId, {
notificationSettings: {
alert: {
"existing-survey-id": true,
"survey-123": true,
},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
});
});
test("should not subscribe user when unsubscribed from organization", async () => {
const mockUser = {
id: "user-123",
notificationSettings: {
alert: { "existing-survey-id": true },
weeklySummary: {},
unsubscribedOrganizationIds: ["org-123"], // User has unsubscribed from this organization
},
} as any;
const surveyId = "survey-123";
const userId = "user-123";
const organizationId = "org-123";
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
await subscribeOrganizationMembersToSurveyResponses(surveyId, userId, organizationId);
// Should not call updateUser because user is unsubscribed from this organization
expect(updateUser).not.toHaveBeenCalled();
});
});
});

View File

@@ -7,8 +7,8 @@ import { ITEMS_PER_PAGE } from "../constants";
import {
getProject,
getProjectByEnvironmentId,
getProjectEnvironmentsByOrganizationIds,
getProjects,
getUserProjectEnvironmentsByOrganizationIds,
getUserProjects,
} from "./service";
@@ -21,6 +21,7 @@ vi.mock("@formbricks/database", () => ({
},
membership: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
},
}));
@@ -488,6 +489,7 @@ describe("Project Service", () => {
test("getProjectsByOrganizationIds should return projects for given organization IDs", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
const mockProjects = [
{
environments: [],
@@ -497,16 +499,34 @@ describe("Project Service", () => {
},
];
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{
userId,
organizationId: organizationId1,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
{
userId,
organizationId: organizationId2,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
]);
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]);
const result = await getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: {
in: [organizationId1, organizationId2],
},
OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }],
},
select: { environments: true },
});
@@ -515,17 +535,36 @@ describe("Project Service", () => {
test("getProjectsByOrganizationIds should return empty array when no projects are found", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{
userId,
organizationId: organizationId1,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
{
userId,
organizationId: organizationId2,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
]);
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]);
const result = await getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual([]);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: {
in: [organizationId1, organizationId2],
},
OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }],
},
select: { environments: true },
});
@@ -534,18 +573,111 @@ describe("Project Service", () => {
test("getProjectsByOrganizationIds should throw DatabaseError when prisma throws", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{
userId,
organizationId: organizationId1,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
]);
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
await expect(getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2])).rejects.toThrow(
DatabaseError
);
await expect(
getUserProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2], userId)
).rejects.toThrow(DatabaseError);
});
test("getProjectsByOrganizationIds should throw ValidationError with wrong input", async () => {
await expect(getProjectEnvironmentsByOrganizationIds(["wrong-id"])).rejects.toThrow(ValidationError);
const userId = createId();
await expect(getUserProjectEnvironmentsByOrganizationIds(["wrong-id"], userId)).rejects.toThrow(
ValidationError
);
});
test("getProjectsByOrganizationIds should return empty array when user has no memberships", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
// Mock no memberships found
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
const result = await getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual([]);
expect(prisma.membership.findMany).toHaveBeenCalledWith({
where: {
userId,
organizationId: {
in: [organizationId1, organizationId2],
},
},
});
// Should not call project.findMany when no memberships
expect(prisma.project.findMany).not.toHaveBeenCalled();
});
test("getProjectsByOrganizationIds should handle member role with team access", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
const mockProjects = [
{
environments: [],
},
];
// Mock membership where user is a member
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{
userId,
organizationId: organizationId1,
role: "member" as any,
accepted: true,
deprecatedRole: null,
},
]);
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
OR: [
{
organizationId: organizationId1,
projectTeams: {
some: {
team: {
teamUsers: {
some: {
userId,
},
},
},
},
},
},
],
},
select: { environments: true },
});
});
});

View File

@@ -171,20 +171,56 @@ export const getOrganizationProjectsCount = reactCache(async (organizationId: st
}
});
export const getProjectEnvironmentsByOrganizationIds = reactCache(
async (organizationIds: string[]): Promise<Pick<TProject, "environments">[]> => {
validateInputs([organizationIds, ZId.array()]);
export const getUserProjectEnvironmentsByOrganizationIds = reactCache(
async (organizationIds: string[], userId: string): Promise<Pick<TProject, "environments">[]> => {
validateInputs([organizationIds, ZId.array()], [userId, ZId]);
try {
if (organizationIds.length === 0) {
return [];
}
const projects = await prisma.project.findMany({
const memberships = await prisma.membership.findMany({
where: {
userId,
organizationId: {
in: organizationIds,
},
},
});
if (memberships.length === 0) {
return [];
}
const whereConditions: Prisma.ProjectWhereInput[] = memberships.map((membership) => {
let projectWhereClause: Prisma.ProjectWhereInput = {
organizationId: membership.organizationId,
};
if (membership.role === "member") {
projectWhereClause = {
...projectWhereClause,
projectTeams: {
some: {
team: {
teamUsers: {
some: {
userId,
},
},
},
},
},
};
}
return projectWhereClause;
});
const projects = await prisma.project.findMany({
where: {
OR: whereConditions,
},
select: { environments: true },
});

View File

@@ -34,7 +34,8 @@
"text": "Du kannst Dich jetzt mit deinem neuen Passwort einloggen"
}
},
"reset_password": "Passwort zurücksetzen"
"reset_password": "Passwort zurücksetzen",
"reset_password_description": "Du wirst abgemeldet, um dein Passwort zurückzusetzen."
},
"invite": {
"create_account": "Konto erstellen",
@@ -191,7 +192,6 @@
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
"email": "E-Mail",
"embed": "Einbetten",
"enterprise_license": "Enterprise Lizenz",
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
@@ -309,7 +309,6 @@
"project_not_found": "Projekt nicht gefunden",
"project_permission_not_found": "Projekt-Berechtigung nicht gefunden",
"projects": "Projekte",
"projects_limit_reached": "Projektlimit erreicht",
"question": "Frage",
"question_id": "Frage-ID",
"questions": "Fragen",
@@ -317,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",
@@ -980,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",
@@ -1231,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:",
@@ -1787,6 +1778,7 @@
"setup_instructions": "Einrichtung",
"setup_integrations": "Integrationen einrichten",
"share_results": "Ergebnisse teilen",
"share_survey": "Umfrage teilen",
"share_the_link": "Teile den Link",
"share_the_link_to_get_responses": "Teile den Link, um Antworten einzusammeln",
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",

View File

@@ -34,7 +34,8 @@
"text": "You can now log in with your new password"
}
},
"reset_password": "Reset password"
"reset_password": "Reset password",
"reset_password_description": "You will be logged out to reset your password."
},
"invite": {
"create_account": "Create an account",
@@ -191,7 +192,6 @@
"e_commerce": "E-Commerce",
"edit": "Edit",
"email": "Email",
"embed": "Embed",
"enterprise_license": "Enterprise License",
"environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.",
@@ -309,7 +309,6 @@
"project_not_found": "Project not found",
"project_permission_not_found": "Project permission not found",
"projects": "Projects",
"projects_limit_reached": "Projects limit reached",
"question": "Question",
"question_id": "Question ID",
"questions": "Questions",
@@ -317,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",
@@ -980,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",
@@ -1231,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:",
@@ -1787,6 +1778,7 @@
"setup_instructions": "Setup instructions",
"setup_integrations": "Setup integrations",
"share_results": "Share results",
"share_survey": "Share survey",
"share_the_link": "Share the link",
"share_the_link_to_get_responses": "Share the link to get responses",
"show_all_responses_that_match": "Show all responses that match",

View File

@@ -34,7 +34,8 @@
"text": "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
}
},
"reset_password": "Réinitialiser le mot de passe"
"reset_password": "Réinitialiser le mot de passe",
"reset_password_description": "Vous serez déconnecté pour réinitialiser votre mot de passe."
},
"invite": {
"create_account": "Créer un compte",
@@ -191,7 +192,6 @@
"e_commerce": "E-commerce",
"edit": "Modifier",
"email": "Email",
"embed": "Intégrer",
"enterprise_license": "Licence d'entreprise",
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
@@ -309,7 +309,6 @@
"project_not_found": "Projet non trouvé",
"project_permission_not_found": "Autorisation de projet non trouvée",
"projects": "Projets",
"projects_limit_reached": "Limite de projets atteinte",
"question": "Question",
"question_id": "ID de la question",
"questions": "Questions",
@@ -317,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",
@@ -980,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",
@@ -1231,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 :",
@@ -1787,6 +1778,7 @@
"setup_instructions": "Instructions d'installation",
"setup_integrations": "Configurer les intégrations",
"share_results": "Partager les résultats",
"share_survey": "Partager l'enquête",
"share_the_link": "Partager le lien",
"share_the_link_to_get_responses": "Partagez le lien pour obtenir des réponses",
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",

View File

@@ -34,7 +34,8 @@
"text": "Agora você pode fazer login com sua nova senha"
}
},
"reset_password": "Redefinir senha"
"reset_password": "Redefinir senha",
"reset_password_description": "Você será desconectado para redefinir sua senha."
},
"invite": {
"create_account": "Cria uma conta",
@@ -191,7 +192,6 @@
"e_commerce": "comércio eletrônico",
"edit": "Editar",
"email": "Email",
"embed": "incorporar",
"enterprise_license": "Licença Empresarial",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
@@ -309,7 +309,6 @@
"project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos",
"projects_limit_reached": "Limites de projetos atingidos",
"question": "Pergunta",
"question_id": "ID da Pergunta",
"questions": "Perguntas",
@@ -317,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",
@@ -357,7 +357,7 @@
"start_free_trial": "Iniciar Teste Grátis",
"status": "status",
"step_by_step_manual": "Manual passo a passo",
"styling": "estilização",
"styling": "Estilização",
"submit": "Enviar",
"summary": "Resumo",
"survey": "Pesquisa",
@@ -369,7 +369,7 @@
"survey_paused": "Pesquisa pausada.",
"survey_scheduled": "Pesquisa agendada.",
"survey_type": "Tipo de Pesquisa",
"surveys": "pesquisas",
"surveys": "Pesquisas",
"switch_organization": "Mudar organização",
"switch_to": "Mudar para {environment}",
"table_items_deleted_successfully": "{type}s deletados com sucesso",
@@ -980,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",
@@ -1231,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:",
@@ -1787,6 +1778,7 @@
"setup_instructions": "Instruções de configuração",
"setup_integrations": "Configurar integrações",
"share_results": "Compartilhar resultados",
"share_survey": "Compartilhar pesquisa",
"share_the_link": "Compartilha o link",
"share_the_link_to_get_responses": "Compartilha o link pra receber respostas",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",

View File

@@ -34,7 +34,8 @@
"text": "Pode agora iniciar sessão com a sua nova palavra-passe"
}
},
"reset_password": "Redefinir palavra-passe"
"reset_password": "Redefinir palavra-passe",
"reset_password_description": "Será desconectado para redefinir a sua palavra-passe."
},
"invite": {
"create_account": "Criar uma conta",
@@ -191,7 +192,6 @@
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
"email": "Email",
"embed": "Incorporar",
"enterprise_license": "Licença Enterprise",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
@@ -309,7 +309,6 @@
"project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos",
"projects_limit_reached": "Limite de projetos atingido",
"question": "Pergunta",
"question_id": "ID da pergunta",
"questions": "Perguntas",
@@ -317,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",
@@ -980,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",
@@ -1231,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:",
@@ -1787,6 +1778,7 @@
"setup_instructions": "Instruções de configuração",
"setup_integrations": "Configurar integrações",
"share_results": "Partilhar resultados",
"share_survey": "Partilhar inquérito",
"share_the_link": "Partilhar o link",
"share_the_link_to_get_responses": "Partilhe o link para obter respostas",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",

View File

@@ -34,7 +34,8 @@
"text": "您現在可以使用新密碼登入"
}
},
"reset_password": "重設密碼"
"reset_password": "重設密碼",
"reset_password_description": "您將被登出以重設您的密碼。"
},
"invite": {
"create_account": "建立帳戶",
@@ -191,7 +192,6 @@
"e_commerce": "電子商務",
"edit": "編輯",
"email": "電子郵件",
"embed": "嵌入",
"enterprise_license": "企業授權",
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
@@ -309,7 +309,6 @@
"project_not_found": "找不到專案",
"project_permission_not_found": "找不到專案權限",
"projects": "專案",
"projects_limit_reached": "已達到專案上限",
"question": "問題",
"question_id": "問題 ID",
"questions": "問題",
@@ -317,6 +316,7 @@
"remove": "移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷",
"request_pricing": "請求定價",
"request_trial_license": "請求試用授權",
"reset_to_default": "重設為預設值",
"response": "回應",
@@ -980,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": "無限回應",
@@ -1231,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. 啟用特定語言的翻譯:",
@@ -1787,6 +1778,7 @@
"setup_instructions": "設定說明",
"setup_integrations": "設定整合",
"share_results": "分享結果",
"share_survey": "分享問卷",
"share_the_link": "分享連結",
"share_the_link_to_get_responses": "分享連結以取得回應",
"show_all_responses_that_match": "顯示所有相符的回應",

View File

@@ -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"
);

View File

@@ -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

View File

@@ -33,10 +33,11 @@ export const validateOtherOptionLengthForMultipleChoice = ({
surveyQuestions,
responseLanguage,
}: {
responseData: TResponseData;
responseData?: TResponseData;
surveyQuestions: TSurveyQuestion[];
responseLanguage?: string;
}): string | undefined => {
if (!responseData) return undefined;
for (const [questionId, answer] of Object.entries(responseData)) {
const question = surveyQuestions.find((q) => q.id === questionId);
if (!question) continue;

View File

@@ -1,7 +1,6 @@
import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ContactAttributeKey } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { ContactAttributeKey, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -55,7 +54,7 @@ export const updateContactAttributeKey = async (
return ok(updatedKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
@@ -106,7 +105,7 @@ export const deleteContactAttributeKey = async (
return ok(deletedKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist

View File

@@ -16,7 +16,7 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
id: ZContactAttributeKeyIdSchema,
}),
},
tags: ["Management API > Contact Attribute Keys"],
tags: ["Management API - Contact Attribute Keys"],
responses: {
"200": {
description: "Contact attribute key retrieved successfully.",
@@ -33,7 +33,7 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttributeKey",
summary: "Update a contact attribute key",
description: "Updates a contact attribute key in the database.",
tags: ["Management API > Contact Attribute Keys"],
tags: ["Management API - Contact Attribute Keys"],
requestParams: {
path: z.object({
id: ZContactAttributeKeyIdSchema,
@@ -64,7 +64,7 @@ export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttributeKey",
summary: "Delete a contact attribute key",
description: "Deletes a contact attribute key from the database.",
tags: ["Management API > Contact Attribute Keys"],
tags: ["Management API - Contact Attribute Keys"],
requestParams: {
path: z.object({
id: ZContactAttributeKeyIdSchema,

View File

@@ -1,6 +1,5 @@
import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { ContactAttributeKey } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { ContactAttributeKey, Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -44,12 +43,12 @@ const mockUpdateInput: TContactAttributeKeyUpdateSchema = {
description: "User's verified email address",
};
const prismaNotFoundError = new PrismaClientKnownRequestError("Mock error message", {
const prismaNotFoundError = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "0.0.1",
});
const prismaUniqueConstraintError = new PrismaClientKnownRequestError("Mock error message", {
const prismaUniqueConstraintError = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});

View File

@@ -5,7 +5,6 @@ import {
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ContactAttributeKey, Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -58,7 +57,7 @@ export const createContactAttributeKey = async (
return ok(createdContactAttributeKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist

View File

@@ -16,7 +16,7 @@ export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributeKeys",
summary: "Get contact attribute keys",
description: "Gets contact attribute keys from the database.",
tags: ["Management API > Contact Attribute Keys"],
tags: ["Management API - Contact Attribute Keys"],
requestParams: {
query: ZGetContactAttributeKeysFilter.sourceType(),
},
@@ -36,7 +36,7 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "createContactAttributeKey",
summary: "Create a contact attribute key",
description: "Creates a contact attribute key in the database.",
tags: ["Management API > Contact Attribute Keys"],
tags: ["Management API - Contact Attribute Keys"],
requestBody: {
required: true,
description: "The contact attribute key to create",

View File

@@ -2,8 +2,7 @@ import {
TContactAttributeKeyInput,
TGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ContactAttributeKey } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { ContactAttributeKey, Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -106,7 +105,7 @@ describe("createContactAttributeKey", () => {
});
test("returns conflict error when key already exists", async () => {
const errToThrow = new PrismaClientKnownRequestError("Mock error message", {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
@@ -129,7 +128,7 @@ describe("createContactAttributeKey", () => {
});
test("returns not found error when related record does not exist", async () => {
const errToThrow = new PrismaClientKnownRequestError("Mock error message", {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "0.0.1",
});

View File

@@ -12,7 +12,7 @@ export const getContactAttributeEndpoint: ZodOpenApiOperationObject = {
contactAttributeId: z.string().cuid2(),
}),
},
tags: ["Management API > Contact Attributes"],
tags: ["Management API - Contact Attributes"],
responses: {
"200": {
description: "Contact retrieved successfully.",
@@ -29,7 +29,7 @@ export const deleteContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttribute",
summary: "Delete a contact attribute",
description: "Deletes a contact attribute from the database.",
tags: ["Management API > Contact Attributes"],
tags: ["Management API - Contact Attributes"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
@@ -51,7 +51,7 @@ export const updateContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContactAttribute",
summary: "Update a contact attribute",
description: "Updates a contact attribute in the database.",
tags: ["Management API > Contact Attributes"],
tags: ["Management API - Contact Attributes"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),

View File

@@ -16,7 +16,7 @@ export const getContactAttributesEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributes",
summary: "Get contact attributes",
description: "Gets contact attributes from the database.",
tags: ["Management API > Contact Attributes"],
tags: ["Management API - Contact Attributes"],
requestParams: {
query: ZGetContactAttributesFilter,
},
@@ -36,7 +36,7 @@ export const createContactAttributeEndpoint: ZodOpenApiOperationObject = {
operationId: "createContactAttribute",
summary: "Create a contact attribute",
description: "Creates a contact attribute in the database.",
tags: ["Management API > Contact Attributes"],
tags: ["Management API - Contact Attributes"],
requestBody: {
required: true,
description: "The contact attribute to create",

View File

@@ -12,7 +12,7 @@ export const getContactEndpoint: ZodOpenApiOperationObject = {
contactId: z.string().cuid2(),
}),
},
tags: ["Management API > Contacts"],
tags: ["Management API - Contacts"],
responses: {
"200": {
description: "Contact retrieved successfully.",
@@ -29,7 +29,7 @@ export const deleteContactEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContact",
summary: "Delete a contact",
description: "Deletes a contact from the database.",
tags: ["Management API > Contacts"],
tags: ["Management API - Contacts"],
requestParams: {
path: z.object({
contactId: z.string().cuid2(),
@@ -51,7 +51,7 @@ export const updateContactEndpoint: ZodOpenApiOperationObject = {
operationId: "updateContact",
summary: "Update a contact",
description: "Updates a contact in the database.",
tags: ["Management API > Contacts"],
tags: ["Management API - Contacts"],
requestParams: {
path: z.object({
contactId: z.string().cuid2(),

View File

@@ -16,7 +16,7 @@ export const getContactsEndpoint: ZodOpenApiOperationObject = {
requestParams: {
query: ZGetContactsFilter,
},
tags: ["Management API > Contacts"],
tags: ["Management API - Contacts"],
responses: {
"200": {
description: "Contacts retrieved successfully.",
@@ -33,7 +33,7 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
operationId: "createContact",
summary: "Create a contact",
description: "Creates a contact in the database.",
tags: ["Management API > Contacts"],
tags: ["Management API - Contacts"],
requestBody: {
required: true,
description: "The contact to create",

View File

@@ -25,7 +25,9 @@ export const getEnvironmentId = async (
*/
export const getEnvironmentIdFromSurveyIds = async (
surveyIds: string[]
): Promise<Result<string, ApiErrorResponseV2>> => {
): Promise<Result<string | null, ApiErrorResponseV2>> => {
if (surveyIds.length === 0) return ok(null);
const result = await fetchEnvironmentIdFromSurveyIds(surveyIds);
if (!result.ok) {

View File

@@ -1,5 +1,5 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
@@ -19,7 +19,7 @@ export const deleteDisplay = async (displayId: string): Promise<Result<boolean,
return ok(true);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist

View File

@@ -14,7 +14,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
id: ZResponseIdSchema,
}),
},
tags: ["Management API > Responses"],
tags: ["Management API - Responses"],
responses: {
"200": {
description: "Response retrieved successfully.",
@@ -31,7 +31,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteResponse",
summary: "Delete a response",
description: "Deletes a response from the database.",
tags: ["Management API > Responses"],
tags: ["Management API - Responses"],
requestParams: {
path: z.object({
id: ZResponseIdSchema,
@@ -53,7 +53,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "updateResponse",
summary: "Update a response",
description: "Updates a response in the database.",
tags: ["Management API > Responses"],
tags: ["Management API - Responses"],
requestParams: {
path: z.object({
id: ZResponseIdSchema,

View File

@@ -3,8 +3,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils";
import { ZResponseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Response } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma, Response } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
@@ -56,7 +55,7 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
return ok(deletedResponse);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
@@ -89,7 +88,7 @@ export const updateResponse = async (
return ok(updatedResponse);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist

View File

@@ -1,5 +1,5 @@
import { displayId, mockDisplay } from "./__mocks__/display.mock";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -39,7 +39,7 @@ describe("Display Lib", () => {
test("return a not_found error when the display is not found", async () => {
vi.mocked(prisma.display.delete).mockRejectedValue(
new PrismaClientKnownRequestError("Display not found", {
new Prisma.PrismaClientKnownRequestError("Display not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {

View File

@@ -1,5 +1,5 @@
import { response, responseId, responseInput, survey } from "./__mocks__/response.mock";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -154,7 +154,7 @@ describe("Response Lib", () => {
test("handle prisma client error code P2025", async () => {
vi.mocked(prisma.response.delete).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", {
new Prisma.PrismaClientKnownRequestError("Response not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {
@@ -208,7 +208,7 @@ describe("Response Lib", () => {
test("return a not_found error when the response is not found", async () => {
vi.mocked(prisma.response.update).mockRejectedValue(
new PrismaClientKnownRequestError("Response not found", {
new Prisma.PrismaClientKnownRequestError("Response not found", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "1.0.0",
meta: {

View File

@@ -16,7 +16,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
requestParams: {
query: ZGetResponsesFilter.sourceType(),
},
tags: ["Management API > Responses"],
tags: ["Management API - Responses"],
responses: {
"200": {
description: "Responses retrieved successfully.",
@@ -33,7 +33,7 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
operationId: "createResponse",
summary: "Create a response",
description: "Creates a response in the database.",
tags: ["Management API > Responses"],
tags: ["Management API - Responses"],
requestBody: {
required: true,
description: "The response to create",

View File

@@ -10,7 +10,7 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
requestParams: {
path: ZContactLinkParams,
},
tags: ["Management API > Surveys > Contact Links"],
tags: ["Management API - Surveys - Contact Links"],
responses: {
"200": {
description: "Personalized survey link retrieved successfully.",

View File

@@ -10,7 +10,7 @@ export const getContactLinksBySegmentEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactLinksBySegment",
summary: "Get survey links for contacts in a segment",
description: "Generates personalized survey links for contacts in a segment.",
tags: ["Management API > Surveys > Contact Links"],
tags: ["Management API - Surveys - Contact Links"],
requestParams: {
path: ZContactLinksBySegmentParams,
query: ZContactLinksBySegmentQuery,

View File

@@ -13,7 +13,7 @@ export const getSurveyEndpoint: ZodOpenApiOperationObject = {
id: surveyIdSchema,
}),
},
tags: ["Management API > Surveys"],
tags: ["Management API - Surveys"],
responses: {
"200": {
description: "Response retrieved successfully.",
@@ -30,7 +30,7 @@ export const deleteSurveyEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteSurvey",
summary: "Delete a survey",
description: "Deletes a survey from the database.",
tags: ["Management API > Surveys"],
tags: ["Management API - Surveys"],
requestParams: {
path: z.object({
id: surveyIdSchema,
@@ -52,7 +52,7 @@ export const updateSurveyEndpoint: ZodOpenApiOperationObject = {
operationId: "updateSurvey",
summary: "Update a survey",
description: "Updates a survey in the database.",
tags: ["Management API > Surveys"],
tags: ["Management API - Surveys"],
requestParams: {
path: z.object({
id: surveyIdSchema,

View File

@@ -1,8 +1,3 @@
// import {
// deleteSurveyEndpoint,
// getSurveyEndpoint,
// updateSurveyEndpoint,
// } from "@/modules/api/v2/management/surveys/[surveyId]/lib/openapi";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { getPersonalizedSurveyLink } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi";
import { ZGetSurveysFilter, ZSurveyInput } from "@/modules/api/v2/management/surveys/types/surveys";
@@ -17,7 +12,7 @@ export const getSurveysEndpoint: ZodOpenApiOperationObject = {
requestParams: {
query: ZGetSurveysFilter,
},
tags: ["Management API > Surveys"],
tags: ["Management API - Surveys"],
responses: {
"200": {
description: "Surveys retrieved successfully.",
@@ -34,7 +29,7 @@ export const createSurveyEndpoint: ZodOpenApiOperationObject = {
operationId: "createSurvey",
summary: "Create a survey",
description: "Creates a survey in the database.",
tags: ["Management API > Surveys"],
tags: ["Management API - Surveys"],
requestBody: {
required: true,
description: "The survey to create",

View File

@@ -14,7 +14,7 @@ export const getWebhookEndpoint: ZodOpenApiOperationObject = {
id: ZWebhookIdSchema,
}),
},
tags: ["Management API > Webhooks"],
tags: ["Management API - Webhooks"],
responses: {
"200": {
description: "Webhook retrieved successfully.",
@@ -31,7 +31,7 @@ export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteWebhook",
summary: "Delete a webhook",
description: "Deletes a webhook from the database.",
tags: ["Management API > Webhooks"],
tags: ["Management API - Webhooks"],
requestParams: {
path: z.object({
id: ZWebhookIdSchema,
@@ -53,7 +53,7 @@ export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
operationId: "updateWebhook",
summary: "Update a webhook",
description: "Updates a webhook in the database.",
tags: ["Management API > Webhooks"],
tags: ["Management API - Webhooks"],
requestParams: {
path: z.object({
id: ZWebhookIdSchema,

View File

@@ -1,5 +1,4 @@
import { WebhookSource } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma, WebhookSource } from "@prisma/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const mockedPrismaWebhookUpdateReturn = {
@@ -14,7 +13,7 @@ export const mockedPrismaWebhookUpdateReturn = {
surveyIds: [],
};
export const prismaNotFoundError = new PrismaClientKnownRequestError("Record does not exist", {
export const prismaNotFoundError = new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "PrismaClient 4.0.0",
});

View File

@@ -1,7 +1,6 @@
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Webhook } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma, Webhook } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -45,7 +44,7 @@ export const updateWebhook = async (
return ok(updatedWebhook);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
@@ -73,7 +72,7 @@ export const deleteWebhook = async (webhookId: string): Promise<Result<Webhook,
return ok(deletedWebhook);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist

View File

@@ -75,13 +75,14 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
);
}
// get surveys environment
const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds);
const surveysEnvironmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (!surveysEnvironmentId.ok) {
return handleApiError(request, surveysEnvironmentId.error, auditLog);
if (!surveysEnvironmentIdResult.ok) {
return handleApiError(request, surveysEnvironmentIdResult.error, auditLog);
}
const surveysEnvironmentId = surveysEnvironmentIdResult.data;
// get webhook environment
const webhook = await getWebhook(params.webhookId);
@@ -101,7 +102,7 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
}
// check if webhook environment matches the surveys environment
if (webhook.data.environmentId !== surveysEnvironmentId.data) {
if (surveysEnvironmentId && webhook.data.environmentId !== surveysEnvironmentId) {
return handleApiError(
request,
{

View File

@@ -16,7 +16,7 @@ export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
requestParams: {
query: ZGetWebhooksFilter.sourceType(),
},
tags: ["Management API > Webhooks"],
tags: ["Management API - Webhooks"],
responses: {
"200": {
description: "Webhooks retrieved successfully.",
@@ -33,7 +33,7 @@ export const createWebhookEndpoint: ZodOpenApiOperationObject = {
operationId: "createWebhook",
summary: "Create a webhook",
description: "Creates a webhook in the database.",
tags: ["Management API > Webhooks"],
tags: ["Management API - Webhooks"],
requestBody: {
required: true,
description: "The webhook to create",

View File

@@ -57,10 +57,12 @@ export const POST = async (request: NextRequest) =>
);
}
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (body.surveyIds && body.surveyIds.length > 0) {
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error, auditLog);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error, auditLog);
}
}
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {

View File

@@ -66,43 +66,43 @@ const document = createDocument({
description: "Operations for managing your API key.",
},
{
name: "Management API > Responses",
name: "Management API - Responses",
description: "Operations for managing responses.",
},
{
name: "Management API > Contacts",
name: "Management API - Contacts",
description: "Operations for managing contacts.",
},
{
name: "Management API > Contact Attributes",
name: "Management API - Contact Attributes",
description: "Operations for managing contact attributes.",
},
{
name: "Management API > Contact Attributes Keys",
description: "Operations for managing contact attributes keys.",
name: "Management API - Contact Attribute Keys",
description: "Operations for managing contact attribute keys.",
},
{
name: "Management API > Surveys",
name: "Management API - Surveys",
description: "Operations for managing surveys.",
},
{
name: "Management API > Surveys > Contact Links",
name: "Management API - Surveys - Contact Links",
description: "Operations for generating personalized survey links for contacts.",
},
{
name: "Management API > Webhooks",
name: "Management API - Webhooks",
description: "Operations for managing webhooks.",
},
{
name: "Organizations API > Teams",
name: "Organizations API - Teams",
description: "Operations for managing teams.",
},
{
name: "Organizations API > Project Teams",
name: "Organizations API - Project Teams",
description: "Operations for managing project teams.",
},
{
name: "Organizations API > Users",
name: "Organizations API - Users",
description: "Operations for managing users.",
},
],

View File

@@ -20,7 +20,7 @@ export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = {
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Project Teams"],
tags: ["Organizations API - Project Teams"],
responses: {
"200": {
description: "Project teams retrieved successfully.",
@@ -42,7 +42,7 @@ export const createProjectTeamEndpoint: ZodOpenApiOperationObject = {
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Project Teams"],
tags: ["Organizations API - Project Teams"],
requestBody: {
required: true,
description: "The project team to create",
@@ -68,7 +68,7 @@ export const deleteProjectTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteProjectTeam",
summary: "Delete a project team",
description: "Deletes a project team from the database.",
tags: ["Organizations API > Project Teams"],
tags: ["Organizations API - Project Teams"],
requestParams: {
query: ZGetProjectTeamUpdateFilter.required(),
path: z.object({
@@ -91,7 +91,7 @@ export const updateProjectTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "updateProjectTeam",
summary: "Update a project team",
description: "Updates a project team in the database.",
tags: ["Organizations API > Project Teams"],
tags: ["Organizations API - Project Teams"],
requestParams: {
path: z.object({
organizationId: ZOrganizationIdSchema,

View File

@@ -16,7 +16,7 @@ export const getTeamEndpoint: ZodOpenApiOperationObject = {
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Teams"],
tags: ["Organizations API - Teams"],
responses: {
"200": {
description: "Team retrieved successfully.",
@@ -33,7 +33,7 @@ export const deleteTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteTeam",
summary: "Delete a team",
description: "Deletes a team from the database.",
tags: ["Organizations API > Teams"],
tags: ["Organizations API - Teams"],
requestParams: {
path: z.object({
id: ZTeamIdSchema,
@@ -56,7 +56,7 @@ export const updateTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "updateTeam",
summary: "Update a team",
description: "Updates a team in the database.",
tags: ["Organizations API > Teams"],
tags: ["Organizations API - Teams"],
requestParams: {
path: z.object({
id: ZTeamIdSchema,

View File

@@ -1,7 +1,6 @@
import { ZTeamUpdateSchema } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Team } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma, Team } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
@@ -51,7 +50,7 @@ export const deleteTeam = async (
return ok(deletedTeam);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
@@ -89,7 +88,7 @@ export const updateTeam = async (
return ok(updatedTeam);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist

View File

@@ -1,4 +1,4 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
@@ -74,7 +74,7 @@ describe("Teams Lib", () => {
test("returns not_found error on known prisma error", async () => {
(prisma.team.delete as any).mockRejectedValueOnce(
new PrismaClientKnownRequestError("Not found", {
new Prisma.PrismaClientKnownRequestError("Not found", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0",
meta: {},
@@ -120,7 +120,7 @@ describe("Teams Lib", () => {
test("returns not_found error when update fails due to missing team", async () => {
(prisma.team.update as any).mockRejectedValueOnce(
new PrismaClientKnownRequestError("Not found", {
new Prisma.PrismaClientKnownRequestError("Not found", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0",
meta: {},

View File

@@ -24,7 +24,7 @@ export const getTeamsEndpoint: ZodOpenApiOperationObject = {
}),
query: ZGetTeamsFilter.sourceType(),
},
tags: ["Organizations API > Teams"],
tags: ["Organizations API - Teams"],
responses: {
"200": {
description: "Teams retrieved successfully.",
@@ -46,7 +46,7 @@ export const createTeamEndpoint: ZodOpenApiOperationObject = {
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Teams"],
tags: ["Organizations API - Teams"],
requestBody: {
required: true,
description: "The team to create",

View File

@@ -20,7 +20,7 @@ export const getUsersEndpoint: ZodOpenApiOperationObject = {
}),
query: ZGetUsersFilter.sourceType(),
},
tags: ["Organizations API > Users"],
tags: ["Organizations API - Users"],
responses: {
"200": {
description: "Users retrieved successfully.",
@@ -42,7 +42,7 @@ export const createUserEndpoint: ZodOpenApiOperationObject = {
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Users"],
tags: ["Organizations API - Users"],
requestBody: {
required: true,
description: "The user to create",
@@ -73,7 +73,7 @@ export const updateUserEndpoint: ZodOpenApiOperationObject = {
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Users"],
tags: ["Organizations API - Users"],
requestBody: {
required: true,
description: "The user to update",

View File

@@ -13,7 +13,13 @@ export const logSignOutAction = async (
userId: string,
userEmail: string,
context: {
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
reason?:
| "user_initiated"
| "account_deletion"
| "email_change"
| "session_timeout"
| "forced_logout"
| "password_reset";
redirectUrl?: string;
organizationId?: string;
}

View File

@@ -1,9 +1,11 @@
"use server";
import { PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { actionClient } from "@/lib/utils/action-client";
import { getUserByEmail } from "@/modules/auth/lib/user";
import { sendForgotPasswordEmail } from "@/modules/email";
import { z } from "zod";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
const ZForgotPasswordAction = z.object({
@@ -13,9 +15,15 @@ const ZForgotPasswordAction = z.object({
export const forgotPasswordAction = actionClient
.schema(ZForgotPasswordAction)
.action(async ({ parsedInput }) => {
if (PASSWORD_RESET_DISABLED) {
throw new OperationNotAllowedError("Password reset is disabled");
}
const user = await getUserByEmail(parsedInput.email);
if (user) {
if (user && user.identityProvider === "email") {
await sendForgotPasswordEmail(user);
}
return { success: true };
});

View File

@@ -1,13 +1,21 @@
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";
interface UseSignOutOptions {
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
reason?:
| "user_initiated"
| "account_deletion"
| "email_change"
| "session_timeout"
| "forced_logout"
| "password_reset";
redirectUrl?: string;
organizationId?: string;
redirect?: boolean;
callbackUrl?: string;
clearEnvironmentId?: boolean;
}
interface SessionUser {
@@ -36,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,

View File

@@ -78,6 +78,7 @@ export const getUserByEmail = reactCache(async (email: string) => {
email: true,
emailVerified: true,
isActive: true,
identityProvider: true,
},
});

View File

@@ -283,7 +283,13 @@ export const logSignOut = (
userId: string,
userEmail: string,
context?: {
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
reason?:
| "user_initiated"
| "account_deletion"
| "email_change"
| "session_timeout"
| "forced_logout"
| "password_reset";
redirectUrl?: string;
organizationId?: string;
}

View File

@@ -48,7 +48,9 @@ describe("EmailChangeSignIn", () => {
expect(screen.getByText("auth.email-change.email_change_success_description")).toBeInTheDocument();
});
expect(signOut).toHaveBeenCalledWith({ redirect: false });
await waitFor(() => {
expect(signOut).toHaveBeenCalledWith({ redirect: false });
});
});
test("handles failed email change verification", async () => {

View File

@@ -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"]);

View File

@@ -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],
};
};

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