Compare commits

...

42 Commits

Author SHA1 Message Date
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
Johannes
8c3e816ccd fix: remove Formbricks branding from Link Pages (#5989)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-16 16:18:25 +00:00
Anshuman Pandey
6ddc91ee85 fix: deletes local storage environment id on logout (#5957) 2025-06-16 14:01:16 +00:00
Saurav Jain
14023ca8a9 fix: keyboard accessibility issue (#3768) (#5941) 2025-06-16 15:45:52 +02:00
Dhruwang Jariwala
385e8a4262 fix: Airtable fix (#5976)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-16 12:37:05 +00:00
Matti Nannt
e358104f7c chore: fast return ping endpoint when telemetry is disabled (#5893)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-16 12:14:07 +00:00
Dhruwang Jariwala
c8e9194ab6 fix: broken email embed for rating question (#5890)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-16 11:49:19 +00:00
Matti Nannt
bebe29815d feat: domain based access control (#5985)
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-06-16 11:37:02 +00:00
victorvhs017
7f40502c94 fix: Removed footer on follow-up email if white labelling enabled (#5984) 2025-06-16 10:59:57 +00:00
Dhruwang Jariwala
5fb5215680 fix: email enumeration via signup page (#5853)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-13 16:25:40 +00:00
Varun Singh
19b80ff042 fix: misplaced button text for 'preview survey' (#5972) 2025-06-13 05:29:41 -07:00
Jakob Schott
2dfdba2acf chore: Optimize text sizing and alignment for Drop-Off table (#5914)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-06-13 11:21:45 +00:00
Johannes
f7842789de docs: tweak API wording (#5978) 2025-06-12 03:45:41 -07:00
Johannes
59bdd5f065 docs: add recall info to variables (#5977) 2025-06-12 03:21:53 -07:00
Jonas Höbenreich
8da1bc71a6 fix: duplicate name survey copy issue (#3865)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-11 10:59:51 +02:00
Piyush Gupta
0e0259691c fix: recall in survey editor (#5938) 2025-06-11 05:33:52 +00:00
Dhruwang Jariwala
ac7831fa3d fix: auth checks in storage management api (#5931) 2025-06-11 04:56:20 +00:00
Dhruwang Jariwala
db32cb392f chore: added curosr rules for database schema (#5935) 2025-06-10 14:01:06 +00:00
Piyush Jain
e5cb01bd88 chore(cache): remove old servers (#5950) 2025-06-10 06:44:19 +00:00
Piyush Gupta
cbef4c2a69 fix: broken templates (#5955)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-06-10 05:04:05 +00:00
Harsh Bhat
86948b70de docs: add EE license activation docs (#5930)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-06-09 12:39:03 +00:00
Dhruwang Jariwala
dfe955ca7c chore: purge cache after deployment (#5934)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-09 11:02:28 +00:00
226 changed files with 4858 additions and 2044 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)

101
.cursor/rules/database.mdc Normal file
View File

@@ -0,0 +1,101 @@
---
description: >
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
and data patterns. It should be used **only when the agent explicitly requests database schema-level
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
globs: []
alwaysApply: agent-requested
---
# Formbricks Database Schema Reference
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
## Database Overview
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
### Core Hierarchy
```
Organization
└── Project
└── Environment (production/development)
├── Survey
├── Contact
├── ActionClass
└── Integration
```
## Schema Reference
For the complete and up-to-date database schema, please refer to:
- Main schema: `packages/database/schema.prisma`
- JSON type definitions: `packages/database/json-types.ts`
The schema.prisma file contains all model definitions, relationships, enums, and field types. The json-types.ts file contains TypeScript type definitions for JSON fields.
## Data Access Patterns
### Multi-tenancy
- All data is scoped by Organization
- Environment-level isolation for surveys and contacts
- Project-level grouping for related surveys
### Soft Deletion
Some models use soft deletion patterns:
- Check `isActive` fields where present
- Use proper filtering in queries
### Cascading Deletes
Configured cascade relationships:
- Organization deletion cascades to all child entities
- Survey deletion removes responses, displays, triggers
- Contact deletion removes attributes and responses
## Common Query Patterns
### Survey with Responses
```typescript
// Include response count and latest responses
const survey = await prisma.survey.findUnique({
where: { id: surveyId },
include: {
responses: {
take: 10,
orderBy: { createdAt: 'desc' }
},
_count: {
select: { responses: true }
}
}
});
```
### Environment Scoping
```typescript
// Always scope by environment
const surveys = await prisma.survey.findMany({
where: {
environmentId: environmentId,
// Additional filters...
}
});
```
### Contact with Attributes
```typescript
const contact = await prisma.contact.findUnique({
where: { id: contactId },
include: {
attributes: {
include: {
attributeKey: true
}
}
}
});
```
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.

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

@@ -80,8 +80,8 @@ S3_ENDPOINT_URL=
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
S3_FORCE_PATH_STYLE=0
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
# SURVEY_URL=https://survey.example.com
# Set this URL to add a public domain for all your client facing routes(default is WEBAPP_URL)
# PUBLIC_URL=https://survey.example.com
#####################
# Disable Features #

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

@@ -4,16 +4,16 @@ on:
workflow_dispatch:
inputs:
VERSION:
description: 'The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0.'
description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
required: true
type: string
REPOSITORY:
description: 'The repository to use for the Docker image'
description: "The repository to use for the Docker image"
required: false
type: string
default: 'ghcr.io/formbricks/formbricks'
default: "ghcr.io/formbricks/formbricks"
ENVIRONMENT:
description: 'The environment to deploy to'
description: "The environment to deploy to"
required: true
type: choice
options:
@@ -22,16 +22,16 @@ on:
workflow_call:
inputs:
VERSION:
description: 'The version of the Docker image to release'
description: "The version of the Docker image to release"
required: true
type: string
REPOSITORY:
description: 'The repository to use for the Docker image'
description: "The repository to use for the Docker image"
required: false
type: string
default: 'ghcr.io/formbricks/formbricks'
default: "ghcr.io/formbricks/formbricks"
ENVIRONMENT:
description: 'The environment to deploy to'
description: "The environment to deploy to"
required: true
type: string
@@ -75,7 +75,7 @@ jobs:
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
with:
helmfile-version: 'v1.0.0'
helmfile-version: "v1.0.0"
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
@@ -92,7 +92,7 @@ jobs:
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
with:
helmfile-version: 'v1.0.0'
helmfile-version: "v1.0.0"
helm-plugins: >
https://github.com/databus23/helm-diff,
https://github.com/jkroepke/helm-secrets
@@ -100,3 +100,43 @@ jobs:
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm
- name: Purge Cloudflare Cache
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
# Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"
fi
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
# Prepare JSON payload for selective cache purge
json_payload=$(cat << EOF
{
"hosts": ["$PURGE_HOST"]
}
EOF
)
# Make API call to Cloudflare
response=$(curl -s -X POST \
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "$json_payload")
echo "Cloudflare API response: $response"
# Verify the operation was successful
if [[ "$(echo "$response" | jq -r .success)" == "true" ]]; then
echo "✅ Successfully purged cache for $PURGE_HOST"
else
echo "❌ Cloudflare cache purge failed"
echo "Error details: $(echo "$response" | jq -r .errors)"
exit 1
fi

1
.gitignore vendored
View File

@@ -73,3 +73,4 @@ infra/terraform/.terraform/
/.idea/
/*.iml
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
.cursorrules

View File

@@ -27,7 +27,7 @@ describe("ConnectWithFormbricks", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
publicDomain={webAppUrl}
widgetSetupCompleted={false}
channel={channel}
/>
@@ -40,7 +40,7 @@ describe("ConnectWithFormbricks", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
publicDomain={webAppUrl}
widgetSetupCompleted={true}
channel={channel}
/>
@@ -53,7 +53,7 @@ describe("ConnectWithFormbricks", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
publicDomain={webAppUrl}
widgetSetupCompleted={true}
channel={channel}
/>
@@ -67,7 +67,7 @@ describe("ConnectWithFormbricks", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
publicDomain={webAppUrl}
widgetSetupCompleted={false}
channel={channel}
/>

View File

@@ -12,14 +12,14 @@ import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
interface ConnectWithFormbricksProps {
environment: TEnvironment;
webAppUrl: string;
publicDomain: string;
widgetSetupCompleted: boolean;
channel: TProjectConfigChannel;
}
export const ConnectWithFormbricks = ({
environment,
webAppUrl,
publicDomain,
widgetSetupCompleted,
channel,
}: ConnectWithFormbricksProps) => {
@@ -49,7 +49,7 @@ export const ConnectWithFormbricks = ({
<div className="flex w-1/2 flex-col space-y-4">
<OnboardingSetupInstructions
environmentId={environment.id}
webAppUrl={webAppUrl}
publicDomain={publicDomain}
channel={channel}
widgetSetupCompleted={widgetSetupCompleted}
/>

View File

@@ -33,7 +33,7 @@ describe("OnboardingSetupInstructions", () => {
// Provide some default props for testing
const defaultProps = {
environmentId: "env-123",
webAppUrl: "https://example.com",
publicDomain: "https://example.com",
channel: "app" as const, // Assuming channel is either "app" or "website"
widgetSetupCompleted: false,
};

View File

@@ -18,14 +18,14 @@ const tabs = [
interface OnboardingSetupInstructionsProps {
environmentId: string;
webAppUrl: string;
publicDomain: string;
channel: TProjectConfigChannel;
widgetSetupCompleted: boolean;
}
export const OnboardingSetupInstructions = ({
environmentId,
webAppUrl,
publicDomain,
channel,
widgetSetupCompleted,
}: OnboardingSetupInstructionsProps) => {
@@ -34,7 +34,7 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var appUrl = "${webAppUrl}";
var appUrl = "${publicDomain}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script>
@@ -44,7 +44,7 @@ export const OnboardingSetupInstructions = ({
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var appUrl = "${webAppUrl}";
var appUrl = "${publicDomain}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script>
@@ -57,7 +57,7 @@ export const OnboardingSetupInstructions = ({
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "${environmentId}",
appUrl: "${webAppUrl}",
appUrl: "${publicDomain}",
});
}
@@ -75,7 +75,7 @@ export const OnboardingSetupInstructions = ({
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "${environmentId}",
appUrl: "${webAppUrl}",
appUrl: "${publicDomain}",
});
}

View File

@@ -1,6 +1,6 @@
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { WEBAPP_URL } from "@/lib/constants";
import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
@@ -30,6 +30,8 @@ const Page = async (props: ConnectPageProps) => {
const channel = project.config.channel || null;
const publicDomain = getPublicDomain();
return (
<div className="flex min-h-full flex-col items-center justify-center py-10">
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
@@ -39,7 +41,7 @@ const Page = async (props: ConnectPageProps) => {
</div>
<ConnectWithFormbricks
environment={environment}
webAppUrl={WEBAPP_URL}
publicDomain={publicDomain}
widgetSetupCompleted={environment.appSetupCompleted}
channel={channel}
/>

View File

@@ -11,7 +11,7 @@ vi.mock("@/lib/constants", () => ({
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
PUBLIC_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",

View File

@@ -14,7 +14,7 @@ vi.mock("@/lib/constants", () => ({
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
PUBLIC_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",

View File

@@ -23,7 +23,6 @@ vi.mock("@/lib/constants", () => ({
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",

View File

@@ -30,6 +30,12 @@ vi.mock("@/lib/constants", () => ({
REDIS_URL: "redis://localhost:6379",
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("Contact Page Re-export", () => {
test("should re-export SingleContactPage", () => {
expect(Page).toBe(SingleContactPage);

View File

@@ -220,6 +220,9 @@ describe("MainNavigation", () => {
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
// Set up localStorage spy on the mocked localStorage
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
render(<MainNavigation {...defaultProps} />);
// Find the avatar and get its parent div which acts as the trigger
@@ -240,6 +243,9 @@ 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",
@@ -247,9 +253,13 @@ describe("MainNavigation", () => {
redirect: false,
callbackUrl: "/auth/login",
});
await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
});
// Clean up spy
removeItemSpy.mockRestore();
});
test("handles organization switching", async () => {

View File

@@ -4,6 +4,7 @@ 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";
@@ -390,6 +391,8 @@ export const MainNavigation = ({
<DropdownMenuItem
onClick={async () => {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",

View File

@@ -29,6 +29,12 @@ vi.mock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://example.com",
},
}));
describe("AppConnectionPage Re-export", () => {
test("should re-export AppConnectionPage correctly", () => {
expect(AppConnectionPage).toBe(OriginalAppConnectionPage);

View File

@@ -29,6 +29,12 @@ vi.mock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: 1,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("GeneralSettingsPage re-export", () => {
test("should re-export GeneralSettingsPage component", () => {
expect(Page).toBe(GeneralSettingsPage);

View File

@@ -29,6 +29,12 @@ vi.mock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: 1,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("ProjectLookSettingsPage re-export", () => {
test("should re-export ProjectLookSettingsPage component", () => {
expect(Page).toBe(ProjectLookSettingsPage);

View File

@@ -34,6 +34,12 @@ vi.mock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: 1,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("TeamsPage re-export", () => {
test("should re-export TeamsPage component", () => {
expect(Page).toBe(TeamsPage);

View File

@@ -49,6 +49,12 @@ vi.mock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
vi.mock("@/app/lib/surveys/surveys");

View File

@@ -20,7 +20,7 @@ interface ResponsePageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
webAppUrl: string;
publicDomain: string;
user?: TUser;
environmentTags: TTag[];
responsesPerPage: number;
@@ -32,7 +32,7 @@ export const ResponsePage = ({
environment,
survey,
surveyId,
webAppUrl,
publicDomain,
user,
environmentTags,
responsesPerPage,
@@ -155,7 +155,7 @@ export const ResponsePage = ({
<>
<div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} />
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} webAppUrl={webAppUrl} />}
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} publicDomain={publicDomain} />}
</div>
<ResponseDataView
survey={survey}

View File

@@ -3,7 +3,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
@@ -65,8 +65,8 @@ vi.mock("@/lib/constants", () => ({
SESSION_MAX_AGE: 1000,
}));
vi.mock("@/lib/getSurveyUrl", () => ({
getSurveyDomain: vi.fn(),
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
@@ -160,7 +160,7 @@ const mockEnvironment = {
const mockTags: TTag[] = [{ id: "tag1", name: "Tag 1", environmentId: mockEnvironmentId } as unknown as TTag];
const mockLocale: TUserLocale = "en-US";
const mockSurveyDomain = "http://customdomain.com";
const mockPublicDomain = "http://customdomain.com";
const mockParams = {
environmentId: mockEnvironmentId,
@@ -179,7 +179,7 @@ describe("ResponsesPage", () => {
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
vi.mocked(getSurveyDomain).mockReturnValue(mockSurveyDomain);
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
});
afterEach(() => {
@@ -205,7 +205,7 @@ describe("ResponsesPage", () => {
survey: mockSurvey,
isReadOnly: false,
user: mockUser,
surveyDomain: mockSurveyDomain,
publicDomain: mockPublicDomain,
}),
undefined
);
@@ -224,7 +224,7 @@ describe("ResponsesPage", () => {
environment: mockEnvironment,
survey: mockSurvey,
surveyId: mockSurveyId,
webAppUrl: "http://localhost:3000",
publicDomain: mockPublicDomain,
environmentTags: mockTags,
user: mockUser,
responsesPerPage: 10,

View File

@@ -1,8 +1,8 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
@@ -37,7 +37,7 @@ const Page = async (props) => {
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const surveyDomain = getSurveyDomain();
const publicDomain = getPublicDomain();
return (
<PageContentWrapper>
@@ -49,7 +49,7 @@ const Page = async (props) => {
survey={survey}
isReadOnly={isReadOnly}
user={user}
surveyDomain={surveyDomain}
publicDomain={publicDomain}
responseCount={responseCount}
/>
}>
@@ -59,7 +59,7 @@ const Page = async (props) => {
environment={environment}
survey={survey}
surveyId={params.surveyId}
webAppUrl={WEBAPP_URL}
publicDomain={publicDomain}
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}

View File

@@ -149,7 +149,7 @@ describe("ShareEmbedSurvey", () => {
const defaultProps = {
survey: mockSurveyWeb,
surveyDomain: "test.com",
publicDomain: "https://public-domain.com",
open: true,
modalView: "start" as "start" | "embed" | "panel",
setOpen: mockSetOpen,
@@ -158,7 +158,7 @@ describe("ShareEmbedSurvey", () => {
beforeEach(() => {
mockEmbedViewComponent.mockImplementation(
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, surveyDomain, locale }) => (
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
<div>
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
@@ -166,7 +166,7 @@ describe("ShareEmbedSurvey", () => {
<div data-testid="embedview-survey-id">{survey.id}</div>
<div data-testid="embedview-email">{email}</div>
<div data-testid="embedview-surveyUrl">{surveyUrl}</div>
<div data-testid="embedview-surveyDomain">{surveyDomain}</div>
<div data-testid="embedview-publicDomain">{publicDomain}</div>
<div data-testid="embedview-locale">{locale}</div>
</div>
)

View File

@@ -24,7 +24,7 @@ import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps {
survey: TSurvey;
surveyDomain: string;
publicDomain: string;
open: boolean;
modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -33,7 +33,7 @@ interface ShareEmbedSurveyProps {
export const ShareEmbedSurvey = ({
survey,
surveyDomain,
publicDomain,
open,
modalView,
setOpen,
@@ -66,16 +66,16 @@ export const ShareEmbedSurvey = ({
useEffect(() => {
const fetchSurveyUrl = async () => {
try {
const url = await getSurveyUrl(survey, surveyDomain, "default");
const url = await getSurveyUrl(survey, publicDomain, "default");
setSurveyUrl(url);
} catch (error) {
console.error("Failed to fetch survey URL:", error);
// Fallback to a default URL if fetching fails
setSurveyUrl(`${surveyDomain}/s/${survey.id}`);
setSurveyUrl(`${publicDomain}/s/${survey.id}`);
}
};
fetchSurveyUrl();
}, [survey, surveyDomain]);
}, [survey, publicDomain]);
useEffect(() => {
if (survey.type !== "link") {
@@ -106,8 +106,7 @@ 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">
@@ -120,7 +119,7 @@ export const ShareEmbedSurvey = ({
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
@@ -174,7 +173,7 @@ export const ShareEmbedSurvey = ({
survey={survey}
email={email}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>

View File

@@ -104,13 +104,15 @@ describe("SummaryDropOffs", () => {
// Check drop-off counts and percentages
expect(screen.getByText("20")).toBeInTheDocument();
expect(screen.getByText("(20%)")).toBeInTheDocument();
expect(screen.getByText("15")).toBeInTheDocument();
expect(screen.getByText("(19%)")).toBeInTheDocument(); // 18.75% rounded to 19%
expect(screen.getByText("10")).toBeInTheDocument();
expect(screen.getByText("(15%)")).toBeInTheDocument(); // 15.38% rounded to 15%
// Check percentage values
const percentageElements = screen.getAllByText(/\d+%/);
expect(percentageElements).toHaveLength(3);
expect(percentageElements[0]).toHaveTextContent("20%");
expect(percentageElements[1]).toHaveTextContent("19%");
expect(percentageElements[2]).toHaveTextContent("15%");
});
test("renders empty state when dropOff array is empty", () => {

View File

@@ -23,9 +23,9 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="">
<div className="grid h-10 grid-cols-6 items-center border-y border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-3 pl-4 md:pl-6">{t("common.questions")}</div>
<div className="flex justify-center">
<div className="grid min-h-10 grid-cols-6 items-center rounded-t-xl border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-3 px-4 md:px-6">{t("common.questions")}</div>
<div className="flex justify-end px-4 md:px-6">
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
@@ -37,14 +37,16 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
</Tooltip>
</TooltipProvider>
</div>
<div className="px-4 text-center md:px-6">{t("environments.surveys.summary.impressions")}</div>
<div className="pr-6 text-center md:pl-6">{t("environments.surveys.summary.drop_offs")}</div>
<div className="px-4 text-right md:px-6">{t("environments.surveys.summary.impressions")}</div>
<div className="px-4 text-right md:mr-1 md:pl-6 md:pr-6">
{t("environments.surveys.summary.drop_offs")}
</div>
</div>
{dropOff.map((quesDropOff) => (
<div
key={quesDropOff.questionId}
className="grid grid-cols-6 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="col-span-3 flex gap-3 pl-4 md:pl-6">
className="grid grid-cols-6 items-start border-b border-slate-100 text-xs text-slate-800 md:text-sm">
<div className="col-span-3 flex gap-3 px-4 py-2 md:px-6">
{getIcon(quesDropOff.questionType)}
<p>
{formatTextWithSlashes(
@@ -57,17 +59,21 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
"default"
)["default"],
"@",
["text-lg"]
["text-sm"]
)}
</p>
</div>
<div className="whitespace-pre-wrap text-center font-semibold">
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
</div>
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
<div className="pl-6 text-center md:px-6">
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
{quesDropOff.impressions}
</div>
<div className="px-4 py-2 text-right md:px-6">
<span className="mr-1 inline-block w-fit rounded-xl bg-slate-100 px-2 py-1 text-left text-xs">
{Math.round(quesDropOff.dropOffPercentage)}%
</span>
<span className="mr-1 font-mono font-medium">{quesDropOff.dropOffCount}</span>
</div>
</div>
))}

View File

@@ -36,7 +36,7 @@ interface SummaryPageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
webAppUrl: string;
publicDomain: string;
locale: TUserLocale;
isReadOnly: boolean;
initialSurveySummary?: TSurveySummary;
@@ -46,7 +46,7 @@ export const SummaryPage = ({
environment,
survey,
surveyId,
webAppUrl,
publicDomain,
locale,
isReadOnly,
initialSurveySummary,
@@ -133,7 +133,7 @@ export const SummaryPage = ({
<div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} />
{!isReadOnly && !isSharingPage && (
<ResultsShareButton survey={surveyMemoized} webAppUrl={webAppUrl} />
<ResultsShareButton survey={surveyMemoized} publicDomain={publicDomain} />
)}
</div>
<ScrollToTop containerId="mainContent" />

View File

@@ -21,6 +21,8 @@ vi.mock("@/modules/ee/audit-logs/lib/utils", () => ({
}),
}));
const mockPublicDomain = "https://public-domain.com";
// Mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
@@ -49,6 +51,12 @@ vi.mock("@/lib/constants", () => ({
REDIS_URL: "mock-url",
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
// Create a spy for refreshSingleUseId so we can override it in tests
const refreshSingleUseIdSpy = vi.fn(() => Promise.resolve("newSingleUseId"));
@@ -72,7 +80,7 @@ vi.mock("next/navigation", () => ({
// Mock copySurveyLink to return a predictable string
vi.mock("@/modules/survey/lib/client-utils", () => ({
copySurveyLink: vi.fn((url: string, id: string) => `${url}?id=${id}`),
copySurveyLink: vi.fn((url: string, suId: string) => `${url}?suId=${suId}`),
}));
// Mock the copy survey action
@@ -103,6 +111,10 @@ vi.mock("@/app/share/[sharingKey]/actions", () => ({
getResponseCountBySurveySharingKeyAction: vi.fn(() => Promise.resolve({ data: 5 })),
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(() => mockPublicDomain),
}));
vi.spyOn(toast, "success");
vi.spyOn(toast, "error");
@@ -123,7 +135,6 @@ const dummySurvey = {
} as unknown as TSurvey;
const dummyEnvironment = { id: "env123", appSetupCompleted: true } as TEnvironment;
const dummyUser = { id: "user123", name: "Test User" } as TUser;
const surveyDomain = "https://surveys.test.formbricks.com";
describe("SurveyAnalysisCTA - handleCopyLink", () => {
afterEach(() => {
@@ -136,7 +147,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -147,9 +158,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
await waitFor(() => {
expect(refreshSingleUseIdSpy).toHaveBeenCalled();
expect(writeTextMock).toHaveBeenCalledWith(
"https://surveys.test.formbricks.com/s/survey123?id=newSingleUseId"
);
expect(writeTextMock).toHaveBeenCalledWith("https://public-domain.com/s/survey123?suId=newSingleUseId");
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
@@ -161,7 +170,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -194,7 +203,7 @@ describe("SurveyAnalysisCTA - Edit functionality", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -215,7 +224,7 @@ describe("SurveyAnalysisCTA - Edit functionality", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={0}
/>
@@ -237,7 +246,7 @@ describe("SurveyAnalysisCTA - Edit functionality", () => {
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -266,7 +275,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -309,7 +318,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -335,7 +344,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>
@@ -369,7 +378,7 @@ describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertD
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
publicDomain={mockPublicDomain}
user={dummyUser}
responseCount={5}
/>

View File

@@ -24,7 +24,7 @@ interface SurveyAnalysisCTAProps {
environment: TEnvironment;
isReadOnly: boolean;
user: TUser;
surveyDomain: string;
publicDomain: string;
responseCount: number;
}
@@ -40,7 +40,7 @@ export const SurveyAnalysisCTA = ({
environment,
isReadOnly,
user,
surveyDomain,
publicDomain,
responseCount,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
@@ -56,7 +56,7 @@ export const SurveyAnalysisCTA = ({
dropdown: false,
});
const surveyUrl = useMemo(() => `${surveyDomain}/s/${survey.id}`, [survey.id, surveyDomain]);
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -202,7 +202,7 @@ export const SurveyAnalysisCTA = ({
<ShareEmbedSurvey
key={key}
survey={survey}
surveyDomain={surveyDomain}
publicDomain={publicDomain}
open={modalState[key as keyof ModalState]}
setOpen={setOpen}
user={user}

View File

@@ -64,7 +64,7 @@ const defaultProps = {
survey: mockSurveyLink,
email: "test@example.com",
surveyUrl: "http://example.com/survey1",
surveyDomain: "http://example.com",
publicDomain: "http://example.com",
setSurveyUrl: vi.fn(),
locale: "en" as any,
disableBack: false,

View File

@@ -20,7 +20,7 @@ interface EmbedViewProps {
survey: any;
email: string;
surveyUrl: string;
surveyDomain: string;
publicDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
locale: TUserLocale;
}
@@ -35,7 +35,7 @@ export const EmbedView = ({
survey,
email,
surveyUrl,
surveyDomain,
publicDomain,
setSurveyUrl,
locale,
}: EmbedViewProps) => {
@@ -83,7 +83,7 @@ export const EmbedView = ({
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>

View File

@@ -6,12 +6,12 @@ import { LinkTab } from "./LinkTab";
// Mock ShareSurveyLink
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: vi.fn(({ survey, surveyUrl, surveyDomain, locale }) => (
ShareSurveyLink: vi.fn(({ survey, surveyUrl, publicDomain, locale }) => (
<div data-testid="share-survey-link">
Mocked ShareSurveyLink
<span data-testid="survey-id">{survey.id}</span>
<span data-testid="survey-url">{surveyUrl}</span>
<span data-testid="survey-domain">{surveyDomain}</span>
<span data-testid="public-domain">{publicDomain}</span>
<span data-testid="locale">{locale}</span>
</div>
)),
@@ -49,7 +49,7 @@ const mockSurvey: TSurvey = {
} as unknown as TSurvey;
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
const mockSurveyDomain = "https://app.formbricks.com";
const mockPublicDomain = "https://app.formbricks.com";
const mockSetSurveyUrl = vi.fn();
const mockLocale: TUserLocale = "en-US";
@@ -82,7 +82,7 @@ describe("LinkTab", () => {
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
surveyDomain={mockSurveyDomain}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
@@ -97,7 +97,7 @@ describe("LinkTab", () => {
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
surveyDomain={mockSurveyDomain}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
@@ -105,7 +105,7 @@ describe("LinkTab", () => {
expect(screen.getByTestId("share-survey-link")).toBeInTheDocument();
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockSurvey.id);
expect(screen.getByTestId("survey-url")).toHaveTextContent(mockSurveyUrl);
expect(screen.getByTestId("survey-domain")).toHaveTextContent(mockSurveyDomain);
expect(screen.getByTestId("public-domain")).toHaveTextContent(mockPublicDomain);
expect(screen.getByTestId("locale")).toHaveTextContent(mockLocale);
});
@@ -114,7 +114,7 @@ describe("LinkTab", () => {
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
surveyDomain={mockSurveyDomain}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
@@ -129,7 +129,7 @@ describe("LinkTab", () => {
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
surveyDomain={mockSurveyDomain}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>

View File

@@ -9,12 +9,12 @@ import { TUserLocale } from "@formbricks/types/user";
interface LinkTabProps {
survey: TSurvey;
surveyUrl: string;
surveyDomain: string;
publicDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}
export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale }: LinkTabProps) => {
export const LinkTab = ({ survey, surveyUrl, publicDomain, setSurveyUrl, locale }: LinkTabProps) => {
const { t } = useTranslate();
const docsLinks = [
@@ -44,7 +44,7 @@ export const LinkTab = ({ survey, surveyUrl, surveyDomain, setSurveyUrl, locale
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>

View File

@@ -1,9 +1,8 @@
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { getTranslate } from "@/tolgee/server";
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
@@ -35,7 +34,16 @@ vi.mock("@/lib/constants", () => ({
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/getSurveyUrl");
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn().mockReturnValue("https://public-domain.com"),
}));
vi.mock("@/lib/project/service");
vi.mock("@/lib/survey/service");
vi.mock("@/lib/utils/styling");
@@ -121,7 +129,7 @@ const mockComputedStyling = {
thankYouCardIconBgColor: "#DDDDDD",
} as any;
const mockSurveyDomain = "https://app.formbricks.com";
const mockPublicDomain = "https://app.formbricks.com";
const mockRawHtml = `${doctype}<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
const mockCleanedHtml = `<html><body>Test Email Content for ${mockSurvey.name}</body></html>`;
@@ -136,7 +144,7 @@ describe("getEmailTemplateHtml", () => {
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
vi.mocked(getStyling).mockReturnValue(mockComputedStyling);
vi.mocked(getSurveyDomain).mockReturnValue(mockSurveyDomain);
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
vi.mocked(getPreviewEmailTemplateHtml).mockResolvedValue(mockRawHtml);
});
@@ -147,8 +155,8 @@ describe("getEmailTemplateHtml", () => {
expect(getSurvey).toHaveBeenCalledWith(mockSurveyId);
expect(getProjectByEnvironmentId).toHaveBeenCalledWith(mockSurvey.environmentId);
expect(getStyling).toHaveBeenCalledWith(mockProject, mockSurvey);
expect(getSurveyDomain).toHaveBeenCalledTimes(1);
const expectedSurveyUrl = `${mockSurveyDomain}/s/${mockSurvey.id}`;
expect(getPublicDomain).toHaveBeenCalledTimes(1);
const expectedSurveyUrl = `${mockPublicDomain}/s/${mockSurvey.id}`;
expect(getPreviewEmailTemplateHtml).toHaveBeenCalledWith(
mockSurvey,
expectedSurveyUrl,

View File

@@ -1,4 +1,4 @@
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
@@ -17,7 +17,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
}
const styling = getStyling(project, survey);
const surveyUrl = getSurveyDomain() + "/s/" + survey.id;
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';

View File

@@ -3,8 +3,8 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import SurveyPage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
@@ -38,7 +38,6 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
WEBAPP_URL: "http://localhost:3000",
RESPONSES_PER_PAGE: 10,
SESSION_MAX_AGE: 1000,
}));
@@ -64,8 +63,8 @@ vi.mock(
})
);
vi.mock("@/lib/getSurveyUrl", () => ({
getSurveyDomain: vi.fn(),
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
@@ -211,7 +210,7 @@ describe("SurveyPage", () => {
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(getSurveyDomain).mockReturnValue("test.domain.com");
vi.mocked(getPublicDomain).mockReturnValue("http://localhost:3000");
vi.mocked(getSurveySummary).mockResolvedValue(mockSurveySummary);
vi.mocked(notFound).mockClear();
});
@@ -235,7 +234,7 @@ describe("SurveyPage", () => {
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);
expect(vi.mocked(getUser)).toHaveBeenCalledWith(mockUserId);
expect(vi.mocked(getSurveyDomain)).toHaveBeenCalled();
expect(vi.mocked(getPublicDomain)).toHaveBeenCalled();
expect(vi.mocked(SurveyAnalysisNavigation).mock.calls[0][0]).toEqual(
expect.objectContaining({
@@ -250,7 +249,7 @@ describe("SurveyPage", () => {
environment: mockEnvironment,
survey: mockSurvey,
surveyId: mockSurveyId,
webAppUrl: WEBAPP_URL,
publicDomain: "http://localhost:3000",
isReadOnly: false,
locale: mockUser.locale ?? DEFAULT_LOCALE,
initialSurveySummary: mockSurveySummary,

View File

@@ -2,8 +2,8 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -40,7 +40,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
const surveyDomain = getSurveyDomain();
const publicDomain = getPublicDomain();
return (
<PageContentWrapper>
@@ -52,7 +52,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
survey={survey}
isReadOnly={isReadOnly}
user={user}
surveyDomain={surveyDomain}
publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
/>
}>
@@ -62,7 +62,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
environment={environment}
survey={survey}
surveyId={params.surveyId}
webAppUrl={WEBAPP_URL}
publicDomain={publicDomain}
isReadOnly={isReadOnly}
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}

View File

@@ -138,7 +138,7 @@ describe("ResultsShareButton", () => {
test("renders initial state and fetches sharing key (no existing key)", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
expect(screen.getByTestId("dropdown-menu-trigger")).toBeInTheDocument();
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
@@ -150,7 +150,7 @@ describe("ResultsShareButton", () => {
test("handles copy private link to clipboard", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
const copyLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
@@ -166,7 +166,9 @@ describe("ResultsShareButton", () => {
test("handles copy public link to clipboard", async () => {
const shareKey = "publicShareKey";
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
render(<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} webAppUrl={webAppUrl} />);
render(
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
const copyPublicLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
@@ -184,7 +186,7 @@ describe("ResultsShareButton", () => {
test("handles publish to web successfully", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
mockGenerateResultShareUrlAction.mockResolvedValue({ data: "newShareKey" });
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
@@ -210,7 +212,9 @@ describe("ResultsShareButton", () => {
const shareKey = "toUnpublishKey";
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
mockDeleteResultShareUrlAction.mockResolvedValue({ data: { id: mockSurvey.id } });
render(<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} webAppUrl={webAppUrl} />);
render(
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const unpublishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
@@ -234,7 +238,7 @@ describe("ResultsShareButton", () => {
test("opens and closes ShareSurveyResults modal", async () => {
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
render(<ResultsShareButton survey={mockSurvey} webAppUrl={webAppUrl} />);
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>

View File

@@ -21,10 +21,10 @@ import { ShareSurveyResults } from "../(analysis)/summary/components/ShareSurvey
interface ResultsShareButtonProps {
survey: TSurvey;
webAppUrl: string;
publicDomain: string;
}
export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProps) => {
export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonProps) => {
const { t } = useTranslate();
const [showResultsLinkModal, setShowResultsLinkModal] = useState(false);
@@ -34,7 +34,7 @@ export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProp
const handlePublish = async () => {
const resultShareKeyResponse = await generateResultShareUrlAction({ surveyId: survey.id });
if (resultShareKeyResponse?.data) {
setSurveyUrl(webAppUrl + "/share/" + resultShareKeyResponse.data);
setSurveyUrl(publicDomain + "/share/" + resultShareKeyResponse.data);
setShowPublishModal(true);
} else {
const errorMessage = getFormattedErrorMessage(resultShareKeyResponse);
@@ -58,13 +58,13 @@ export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProp
const fetchSharingKey = async () => {
const resultShareUrlResponse = await getResultShareUrlAction({ surveyId: survey.id });
if (resultShareUrlResponse?.data) {
setSurveyUrl(webAppUrl + "/share/" + resultShareUrlResponse.data);
setSurveyUrl(publicDomain + "/share/" + resultShareUrlResponse.data);
setShowPublishModal(true);
}
};
fetchSharingKey();
}, [survey.id, webAppUrl]);
}, [survey.id, publicDomain]);
const copyUrlToClipboard = () => {
if (typeof window !== "undefined") {

View File

@@ -14,41 +14,64 @@ describe("ClientEnvironmentRedirect", () => {
cleanup();
});
test("should redirect to the provided environment ID when no last environment exists", () => {
test("should redirect to the first environment ID when no last environment exists", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage
const localStorageMock = {
getItem: vi.fn().mockReturnValue(null),
removeItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
render(<ClientEnvironmentRedirect userEnvironments={["test-env-id"]} />);
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id");
});
test("should redirect to the last environment ID when it exists in localStorage", () => {
test("should redirect to the last environment ID when it exists in localStorage and is valid", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage with a last environment ID
const localStorageMock = {
getItem: vi.fn().mockReturnValue("last-env-id"),
removeItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
render(<ClientEnvironmentRedirect userEnvironments={["last-env-id", "other-env-id"]} />);
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id");
});
test("should clear invalid environment ID and redirect to default when stored ID is not in user environments", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage with an invalid environment ID
const localStorageMock = {
getItem: vi.fn().mockReturnValue("invalid-env-id"),
removeItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect userEnvironments={["valid-env-1", "valid-env-2"]} />);
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(localStorageMock.removeItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(mockPush).toHaveBeenCalledWith("/environments/valid-env-1");
});
test("should update redirect when environment ID prop changes", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
@@ -56,19 +79,20 @@ describe("ClientEnvironmentRedirect", () => {
// Mock localStorage
const localStorageMock = {
getItem: vi.fn().mockReturnValue(null),
removeItem: vi.fn(),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
const { rerender } = render(<ClientEnvironmentRedirect environmentId="initial-env-id" />);
const { rerender } = render(<ClientEnvironmentRedirect userEnvironments={["initial-env-id"]} />);
expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id");
// Clear mock calls
mockPush.mockClear();
// Rerender with new environment ID
rerender(<ClientEnvironmentRedirect environmentId="new-env-id" />);
rerender(<ClientEnvironmentRedirect userEnvironments={["new-env-id"]} />);
expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id");
});
});

View File

@@ -5,22 +5,23 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
interface ClientEnvironmentRedirectProps {
environmentId: string;
userEnvironments: string[];
}
const ClientEnvironmentRedirect = ({ environmentId }: ClientEnvironmentRedirectProps) => {
const ClientEnvironmentRedirect = ({ userEnvironments }: ClientEnvironmentRedirectProps) => {
const router = useRouter();
useEffect(() => {
const lastEnvironmentId = localStorage.getItem(FORMBRICKS_ENVIRONMENT_ID_LS);
if (lastEnvironmentId) {
// Redirect to the last environment the user was in
if (lastEnvironmentId && userEnvironments.includes(lastEnvironmentId)) {
router.push(`/environments/${lastEnvironmentId}`);
} else {
router.push(`/environments/${environmentId}`);
// If the last environmentId is not valid, remove it from localStorage and redirect to the provided environmentId
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
router.push(`/environments/${userEnvironments[0]}`);
}
}, [environmentId, router]);
}, [userEnvironments, router]);
return null;
};

View File

@@ -1,5 +1,6 @@
import { responses } from "@/app/lib/api/response";
import { CRON_SECRET } from "@/lib/constants";
import { env } from "@/lib/env";
import { captureTelemetry } from "@/lib/telemetry";
import packageJson from "@/package.json";
import { headers } from "next/headers";
@@ -13,6 +14,10 @@ export const POST = async () => {
return responses.notAuthenticatedResponse();
}
if (env.TELEMETRY_DISABLED === "1") {
return responses.successResponse({}, true);
}
const [surveyCount, responseCount, userCount] = await Promise.all([
prisma.survey.count(),
prisma.response.count(),

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

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

@@ -0,0 +1,212 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Session } from "next-auth";
import { NextRequest } from "next/server";
import { describe, expect, test } from "vitest";
import { vi } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { checkForRequiredFields } from "./utils";
import { checkAuth } from "./utils";
// Create mock response objects
const mockBadRequestResponse = new Response("Bad Request", { status: 400 });
const mockNotAuthenticatedResponse = new Response("Not authenticated", { status: 401 });
const mockUnauthorizedResponse = new Response("Unauthorized", { status: 401 });
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("@/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({
hasPermission: vi.fn(),
}));
vi.mock("@/app/lib/api/response", () => ({
responses: {
badRequestResponse: vi.fn(() => mockBadRequestResponse),
notAuthenticatedResponse: vi.fn(() => mockNotAuthenticatedResponse),
unauthorizedResponse: vi.fn(() => mockUnauthorizedResponse),
},
}));
describe("checkForRequiredFields", () => {
test("should return undefined when all required fields are present", () => {
const result = checkForRequiredFields("env-123", "image/png", "test-file.png");
expect(result).toBeUndefined();
});
test("should return bad request response when environmentId is missing", () => {
const result = checkForRequiredFields("", "image/png", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when fileType is missing", () => {
const result = checkForRequiredFields("env-123", "", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when encodedFileName is missing", () => {
const result = checkForRequiredFields("env-123", "image/png", "");
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when environmentId is undefined", () => {
const result = checkForRequiredFields(undefined as any, "image/png", "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("environmentId is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when fileType is undefined", () => {
const result = checkForRequiredFields("env-123", undefined as any, "test-file.png");
expect(responses.badRequestResponse).toHaveBeenCalledWith("contentType is required");
expect(result).toBe(mockBadRequestResponse);
});
test("should return bad request response when encodedFileName is undefined", () => {
const result = checkForRequiredFields("env-123", "image/png", undefined as any);
expect(responses.badRequestResponse).toHaveBeenCalledWith("fileName is required");
expect(result).toBe(mockBadRequestResponse);
});
});
describe("checkAuth", () => {
const environmentId = "env-123";
const mockRequest = new NextRequest("http://localhost:3000/api/test");
test("returns notAuthenticatedResponse when no session and no authentication", async () => {
vi.mocked(authenticateRequest).mockResolvedValue(null);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(responses.notAuthenticatedResponse).toHaveBeenCalled();
expect(result).toBe(mockNotAuthenticatedResponse);
});
test("returns unauthorizedResponse when no session and authentication lacks POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
{
environmentId: "env-123",
permission: "read",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {},
},
};
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(false);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(
mockAuthentication.environmentPermissions,
environmentId,
"POST"
);
expect(responses.unauthorizedResponse).toHaveBeenCalled();
expect(result).toBe(mockUnauthorizedResponse);
});
test("returns undefined when no session and authentication has POST permission", async () => {
const mockAuthentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: [
{
environmentId: "env-123",
permission: "write",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {},
},
};
vi.mocked(authenticateRequest).mockResolvedValue(mockAuthentication);
vi.mocked(hasPermission).mockReturnValue(true);
const result = await checkAuth(null, environmentId, mockRequest);
expect(authenticateRequest).toHaveBeenCalledWith(mockRequest);
expect(hasPermission).toHaveBeenCalledWith(
mockAuthentication.environmentPermissions,
environmentId,
"POST"
);
expect(result).toBeUndefined();
});
test("returns unauthorizedResponse when session exists but user lacks environment access", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(false);
const result = await checkAuth(mockSession, environmentId, mockRequest);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(responses.unauthorizedResponse).toHaveBeenCalled();
expect(result).toBe(mockUnauthorizedResponse);
});
test("returns undefined when session exists and user has environment access", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
const result = await checkAuth(mockSession, environmentId, mockRequest);
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
expect(result).toBeUndefined();
});
test("does not call authenticateRequest when session exists", async () => {
const mockSession: Session = {
user: {
id: "user-123",
},
expires: "2024-12-31T23:59:59.999Z",
};
vi.mocked(hasUserEnvironmentAccess).mockResolvedValue(true);
await checkAuth(mockSession, environmentId, mockRequest);
expect(authenticateRequest).not.toHaveBeenCalled();
expect(hasUserEnvironmentAccess).toHaveBeenCalledWith("user-123", environmentId);
});
});

View File

@@ -0,0 +1,41 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Session } from "next-auth";
import { NextRequest } from "next/server";
export const checkForRequiredFields = (
environmentId: string,
fileType: string,
encodedFileName: string
): Response | undefined => {
if (!environmentId) {
return responses.badRequestResponse("environmentId is required");
}
if (!fileType) {
return responses.badRequestResponse("contentType is required");
}
if (!encodedFileName) {
return responses.badRequestResponse("fileName is required");
}
};
export const checkAuth = async (session: Session | null, environmentId: string, request: NextRequest) => {
if (!session) {
//check whether its using API key
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
} else {
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
}
};

View File

@@ -1,10 +1,10 @@
// headers -> "Content-Type" should be present and set to a valid MIME type
// body -> should be a valid file object (buffer)
// method -> PUT (to be the same as the signedUrl method)
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
import { validateLocalSignedUrl } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { validateFile } from "@/lib/fileValidation";
import { putFileToLocalStorage } from "@/lib/storage/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
@@ -27,41 +27,17 @@ export const POST = async (req: NextRequest): Promise<Response> => {
const signedTimestamp = jsonInput.timestamp as string;
const environmentId = jsonInput.environmentId as string;
if (!environmentId) {
return responses.badRequestResponse("environmentId is required");
}
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, encodedFileName);
if (requiredFieldResponse) return requiredFieldResponse;
if (!fileType) {
return responses.badRequestResponse("contentType is required");
}
if (!encodedFileName) {
return responses.badRequestResponse("fileName is required");
}
if (!signedSignature) {
return responses.unauthorizedResponse();
}
if (!signedUuid) {
return responses.unauthorizedResponse();
}
if (!signedTimestamp) {
if (!signedSignature || !signedUuid || !signedTimestamp) {
return responses.unauthorizedResponse();
}
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return responses.notAuthenticatedResponse();
}
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
const authResponse = await checkAuth(session, environmentId, req);
if (authResponse) return authResponse;
const fileName = decodeURIComponent(encodedFileName);

View File

@@ -1,5 +1,5 @@
import { checkAuth, checkForRequiredFields } from "@/app/api/v1/management/storage/lib/utils";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { validateFile } from "@/lib/fileValidation";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
@@ -13,29 +13,24 @@ import { getSignedUrlForPublicFile } from "./lib/getSignedUrl";
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
// this api endpoint will return a signed url for uploading the file to s3 and another url for uploading file to the local storage
export const POST = async (req: NextRequest): Promise<Response> => {
export const POST = async (request: NextRequest): Promise<Response> => {
let storageInput;
try {
storageInput = await req.json();
storageInput = await request.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON input");
logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
const { fileName, fileType, environmentId, allowedFileExtensions } = storageInput;
if (!fileName) {
return responses.badRequestResponse("fileName is required");
}
const requiredFieldResponse = checkForRequiredFields(environmentId, fileType, fileName);
if (requiredFieldResponse) return requiredFieldResponse;
const session = await getServerSession(authOptions);
if (!fileType) {
return responses.badRequestResponse("fileType is required");
}
if (!environmentId) {
return responses.badRequestResponse("environmentId is required");
}
const authResponse = await checkAuth(session, environmentId, request);
if (authResponse) return authResponse;
// Perform server-side file validation first to block dangerous file types
const fileValidation = validateFile(fileName, fileType);
@@ -53,18 +48,5 @@ export const POST = async (req: NextRequest): Promise<Response> => {
}
}
// auth and upload private file
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return responses.notAuthenticatedResponse();
}
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isUserAuthorized) {
return responses.unauthorizedResponse();
}
return await getSignedUrlForPublicFile(fileName, environmentId, fileType);
};

View File

@@ -1,6 +1,6 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -42,10 +42,10 @@ export const GET = async (
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
const surveyDomain = getSurveyDomain();
const publicDomain = getPublicDomain();
// map single use ids to survey links
const surveyLinks = singleUseIds.map(
(singleUseId) => `${surveyDomain}/s/${survey.id}?suId=${singleUseId}`
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
);
return responses.successResponse(surveyLinks);

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,
@@ -309,7 +309,7 @@ export const createJumpLogic = (
// Helper function to create jump logic based on choice selection
export const createChoiceJumpLogic = (
sourceQuestionId: string,
choiceId: string,
choiceId: string | number,
targetId: string
): TSurveyLogic => ({
id: createId(),

View File

@@ -1210,6 +1210,7 @@ const feedbackBox = (t: TFnType): TTemplate => {
t("templates.feedback_box_question_1_choice_1"),
t("templates.feedback_box_question_1_choice_2"),
],
choiceIds: [reusableOptionIds[0], reusableOptionIds[1]],
headline: t("templates.feedback_box_question_1_headline"),
required: true,
subheader: t("templates.feedback_box_question_1_subheader"),
@@ -2054,7 +2055,7 @@ const professionalDevelopmentSurvey = (t: TFnType): TTemplate => {
shuffleOption: "none",
choices: [
t("templates.professional_development_survey_question_1_choice_1"),
t("templates.professional_development_survey_question_1_choice_1"),
t("templates.professional_development_survey_question_1_choice_2"),
],
t,
}),
@@ -2381,6 +2382,7 @@ const measureTaskAccomplishment = (t: TFnType): TTemplate => {
t("templates.measure_task_accomplishment_question_1_option_2_label"),
t("templates.measure_task_accomplishment_question_1_option_3_label"),
],
choiceIds: [reusableOptionIds[0], reusableOptionIds[1], reusableOptionIds[2]],
headline: t("templates.measure_task_accomplishment_question_1_headline"),
required: true,
t,
@@ -2739,10 +2741,10 @@ const understandPurchaseIntention = (t: TFnType): TTemplate => {
buildRatingQuestion({
id: reusableQuestionIds[0],
logic: [
createChoiceJumpLogic(reusableQuestionIds[0], "2", reusableQuestionIds[1]),
createChoiceJumpLogic(reusableQuestionIds[0], "3", reusableQuestionIds[2]),
createChoiceJumpLogic(reusableQuestionIds[0], "4", reusableQuestionIds[2]),
createChoiceJumpLogic(reusableQuestionIds[0], "5", localSurvey.endings[0].id),
createChoiceJumpLogic(reusableQuestionIds[0], 2, reusableQuestionIds[1]),
createChoiceJumpLogic(reusableQuestionIds[0], 3, reusableQuestionIds[2]),
createChoiceJumpLogic(reusableQuestionIds[0], 4, reusableQuestionIds[2]),
createChoiceJumpLogic(reusableQuestionIds[0], 5, localSurvey.endings[0].id),
],
range: 5,
scale: "number",
@@ -2795,7 +2797,7 @@ const improveNewsletterContent = (t: TFnType): TTemplate => {
buildRatingQuestion({
id: reusableQuestionIds[0],
logic: [
createChoiceJumpLogic(reusableQuestionIds[0], "5", reusableQuestionIds[2]),
createChoiceJumpLogic(reusableQuestionIds[0], 5, reusableQuestionIds[2]),
{
id: createId(),
conditions: {
@@ -2895,8 +2897,8 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
buildRatingQuestion({
id: reusableQuestionIds[1],
logic: [
createChoiceJumpLogic(reusableQuestionIds[1], "3", reusableQuestionIds[2]),
createChoiceJumpLogic(reusableQuestionIds[1], "4", reusableQuestionIds[3]),
createChoiceJumpLogic(reusableQuestionIds[1], 3, reusableQuestionIds[2]),
createChoiceJumpLogic(reusableQuestionIds[1], 4, reusableQuestionIds[3]),
],
range: 5,
scale: "number",
@@ -2928,8 +2930,8 @@ const evaluateAProductIdea = (t: TFnType): TTemplate => {
buildRatingQuestion({
id: reusableQuestionIds[4],
logic: [
createChoiceJumpLogic(reusableQuestionIds[4], "3", reusableQuestionIds[5]),
createChoiceJumpLogic(reusableQuestionIds[4], "4", reusableQuestionIds[6]),
createChoiceJumpLogic(reusableQuestionIds[4], 3, reusableQuestionIds[5]),
createChoiceJumpLogic(reusableQuestionIds[4], 4, reusableQuestionIds[6]),
],
range: 5,
scale: "number",
@@ -3004,6 +3006,7 @@ const understandLowEngagement = (t: TFnType): TTemplate => {
t("templates.understand_low_engagement_question_1_choice_4"),
t("templates.understand_low_engagement_question_1_choice_5"),
],
choiceIds: [reusableOptionIds[0], reusableOptionIds[1], reusableOptionIds[2], reusableOptionIds[3]],
headline: t("templates.understand_low_engagement_question_1_headline"),
required: true,
containsOther: true,

View File

@@ -0,0 +1,85 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getPublicDomainHost, isPublicDomainConfigured, isRequestFromPublicDomain } from "./domain-utils";
// Mock the env module
vi.mock("@/lib/env", () => ({
env: {
get PUBLIC_URL() {
return process.env.PUBLIC_URL || "";
},
},
}));
describe("Domain Utils", () => {
beforeEach(() => {
process.env.PUBLIC_URL = "";
});
describe("getPublicDomain", () => {
test("should return null when PUBLIC_URL is empty", () => {
expect(getPublicDomainHost()).toBeNull();
});
test("should return the host from a valid PUBLIC_URL", () => {
process.env.PUBLIC_URL = "https://example.com";
expect(getPublicDomainHost()).toBe("example.com");
});
test("should handle URLs with paths", () => {
process.env.PUBLIC_URL = "https://example.com/path";
expect(getPublicDomainHost()).toBe("example.com");
});
test("should handle URLs with ports", () => {
process.env.PUBLIC_URL = "https://example.com:3000";
expect(getPublicDomainHost()).toBe("example.com:3000");
});
});
describe("isPublicDomainConfigured", () => {
test("should return false when PUBLIC_URL is empty", () => {
process.env.PUBLIC_URL = "";
expect(isPublicDomainConfigured()).toBe(false);
});
test("should return true when PUBLIC_URL is valid", () => {
process.env.PUBLIC_URL = "https://example.com";
expect(isPublicDomainConfigured()).toBe(true);
});
});
describe("isRequestFromPublicDomain", () => {
test("should return false when public domain is not configured", () => {
process.env.PUBLIC_URL = "";
const request = new NextRequest("https://example.com");
expect(isRequestFromPublicDomain(request)).toBe(false);
});
test("should return false when host doesn't match public domain", () => {
process.env.PUBLIC_URL = "https://example.com";
const request = new NextRequest("https://different-domain.com");
expect(isRequestFromPublicDomain(request)).toBe(false);
});
test("should return true when host matches public domain", () => {
process.env.PUBLIC_URL = "https://example.com";
const request = new NextRequest("https://example.com", {
headers: {
host: "example.com",
},
});
expect(isRequestFromPublicDomain(request)).toBe(true);
});
test("should handle domains with ports", () => {
process.env.PUBLIC_URL = "https://example.com:3000";
const request = new NextRequest("https://example.com:3000", {
headers: {
host: "example.com:3000",
},
});
expect(isRequestFromPublicDomain(request)).toBe(true);
});
});
});

View File

@@ -0,0 +1,31 @@
import { env } from "@/lib/env";
import { NextRequest } from "next/server";
/**
* Get the public domain from PUBLIC_URL environment variable
*/
export const getPublicDomainHost = (): string | null => {
const PUBLIC_URL = env.PUBLIC_URL;
if (!PUBLIC_URL) return null;
return new URL(PUBLIC_URL).host;
};
/**
* Check if PUBLIC_URL is configured (has a valid public domain)
*/
export const isPublicDomainConfigured = (): boolean => {
return getPublicDomainHost() !== null;
};
/**
* Check if the current request is coming from the public domain
*/
export const isRequestFromPublicDomain = (request: NextRequest): boolean => {
const host = request.headers.get("host");
const publicDomainHost = getPublicDomainHost();
if (!publicDomainHost) return false;
return host === publicDomainHost;
};

View File

@@ -1,10 +1,13 @@
import { describe, expect, test } from "vitest";
import {
isAdminDomainRoute,
isAuthProtectedRoute,
isClientSideApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isManagementApiRoute,
isPublicDomainRoute,
isRouteAllowedForDomain,
isShareUrlRoute,
isSignupRoute,
isSyncWithUserIdentificationEndpoint,
@@ -69,6 +72,9 @@ describe("endpoint-validator", () => {
expect(isClientSideApiRoute("/api/v1/management/something")).toBe(false);
expect(isClientSideApiRoute("/api/something")).toBe(false);
expect(isClientSideApiRoute("/auth/login")).toBe(false);
// exception for open graph image generation route, it should not be rate limited
expect(isClientSideApiRoute("/api/v1/client/og")).toBe(false);
});
});
@@ -136,4 +142,138 @@ describe("endpoint-validator", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
});
});
describe("isPublicDomainRoute", () => {
test("should return true for health endpoint", () => {
expect(isPublicDomainRoute("/health")).toBe(true);
});
// Static assets are not handled by domain routing - middleware doesn't run on them
test("should return true for survey routes", () => {
expect(isPublicDomainRoute("/s/survey123")).toBe(true);
expect(isPublicDomainRoute("/s/survey-id-with-dashes")).toBe(true);
});
test("should return true for contact survey routes", () => {
expect(isPublicDomainRoute("/c/jwt-token")).toBe(true);
expect(isPublicDomainRoute("/c/very-long-jwt-token-123")).toBe(true);
});
test("should return true for client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
});
test("should return true for share routes", () => {
expect(isPublicDomainRoute("/share/abc123/summary")).toBe(true);
expect(isPublicDomainRoute("/share/xyz789/responses")).toBe(true);
expect(isPublicDomainRoute("/share/anything")).toBe(true);
});
test("should return false for admin-only routes", () => {
expect(isPublicDomainRoute("/")).toBe(false);
expect(isPublicDomainRoute("/environments/123")).toBe(false);
expect(isPublicDomainRoute("/auth/login")).toBe(false);
expect(isPublicDomainRoute("/setup/organization")).toBe(false);
expect(isPublicDomainRoute("/organizations/123")).toBe(false);
expect(isPublicDomainRoute("/product/settings")).toBe(false);
expect(isPublicDomainRoute("/api/v1/management/users")).toBe(false);
expect(isPublicDomainRoute("/api/v2/management/surveys")).toBe(false);
});
});
describe("isAdminDomainRoute", () => {
test("should return true for health endpoint (backward compatibility)", () => {
expect(isAdminDomainRoute("/health")).toBe(true);
expect(isAdminDomainRoute("/health")).toBe(true);
});
// Static assets are not handled by domain routing - middleware doesn't run on them
test("should return true for admin routes", () => {
expect(isAdminDomainRoute("/")).toBe(true);
expect(isAdminDomainRoute("/environments/123")).toBe(true);
expect(isAdminDomainRoute("/environments/123/surveys")).toBe(true);
expect(isAdminDomainRoute("/auth/login")).toBe(true);
expect(isAdminDomainRoute("/auth/signup")).toBe(true);
expect(isAdminDomainRoute("/setup/organization")).toBe(true);
expect(isAdminDomainRoute("/setup/team")).toBe(true);
expect(isAdminDomainRoute("/organizations/123")).toBe(true);
expect(isAdminDomainRoute("/organizations/123/settings")).toBe(true);
expect(isAdminDomainRoute("/product/settings")).toBe(true);
expect(isAdminDomainRoute("/product/features")).toBe(true);
expect(isAdminDomainRoute("/api/v1/management/users")).toBe(true);
expect(isAdminDomainRoute("/api/v2/management/surveys")).toBe(true);
expect(isAdminDomainRoute("/pipeline/jobs")).toBe(true);
expect(isAdminDomainRoute("/cron/tasks")).toBe(true);
expect(isAdminDomainRoute("/random/route")).toBe(true);
expect(isAdminDomainRoute("/s/survey123")).toBe(false);
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
});
});
describe("isRouteAllowedForDomain", () => {
test("should allow public routes on public domain", () => {
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
expect(isRouteAllowedForDomain("/share/abc/summary", true)).toBe(true);
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
// Static assets not tested - middleware doesn't run on them
});
test("should block admin routes on public domain", () => {
expect(isRouteAllowedForDomain("/", true)).toBe(false);
expect(isRouteAllowedForDomain("/environments/123", true)).toBe(false);
expect(isRouteAllowedForDomain("/auth/login", true)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/management/users", true)).toBe(false);
});
test("should block public routes on admin domain when PUBLIC_URL is configured", () => {
// Admin routes should be allowed
expect(isRouteAllowedForDomain("/", false)).toBe(true);
expect(isRouteAllowedForDomain("/environments/123", false)).toBe(true);
expect(isRouteAllowedForDomain("/auth/login", false)).toBe(true);
expect(isRouteAllowedForDomain("/api/v1/management/users", false)).toBe(true);
expect(isRouteAllowedForDomain("/health", false)).toBe(true);
expect(isRouteAllowedForDomain("/pipeline/jobs", false)).toBe(true);
expect(isRouteAllowedForDomain("/cron/tasks", false)).toBe(true);
// Public routes should be blocked on admin domain
expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false);
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
expect(isRouteAllowedForDomain("/share/abc/summary", false)).toBe(false);
});
});
describe("edge cases", () => {
test("should handle empty paths", () => {
expect(isPublicDomainRoute("")).toBe(false);
expect(isAdminDomainRoute("")).toBe(true);
expect(isAdminDomainRoute("")).toBe(true);
});
test("should handle paths with query parameters", () => {
expect(isPublicDomainRoute("/s/survey123?param=value")).toBe(true);
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
});
test("should handle paths with fragments", () => {
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
});
test("should handle nested survey routes", () => {
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
});
test("should handle nested client API routes", () => {
expect(isPublicDomainRoute("/api/v1/client/env123/actions")).toBe(true);
expect(isPublicDomainRoute("/api/v2/client/env456/responses")).toBe(true);
});
});
});

View File

@@ -1,3 +1,9 @@
import {
getAllPubliclyAccessibleRoutePatterns,
getPublicDomainRoutePatterns,
matchesAnyPattern,
} from "./route-config";
export const isLoginRoute = (url: string) =>
url === "/api/auth/callback/credentials" || url === "/auth/login";
@@ -8,6 +14,9 @@ export const isVerifyEmailRoute = (url: string) => url === "/auth/verify-email";
export const isForgotPasswordRoute = (url: string) => url === "/auth/forgot-password";
export const isClientSideApiRoute = (url: string): boolean => {
// Open Graph image generation route is a client side API route but it should not be rate limited
if (url.includes("/api/v1/client/og")) return false;
if (url.includes("/api/v1/js/actions")) return true;
if (url.includes("/api/v1/client/storage")) return true;
const regex = /^\/api\/v\d+\/client\//;
@@ -38,3 +47,39 @@ export const isSyncWithUserIdentificationEndpoint = (
const match = url.match(regex);
return match ? { environmentId: match.groups!.environmentId, userId: match.groups!.userId } : false;
};
/**
* Check if the route should be accessible on the public domain (PUBLIC_URL)
* Uses whitelist approach - only explicitly allowed routes are accessible
*/
export const isPublicDomainRoute = (url: string): boolean => {
const publicRoutePatterns = getAllPubliclyAccessibleRoutePatterns();
return matchesAnyPattern(url, publicRoutePatterns);
};
/**
* Check if the route should be accessible on the admin domain (WEBAPP_URL)
* When PUBLIC_URL is configured, admin domain should only allow admin-specific routes + health
*/
export const isAdminDomainRoute = (url: string): boolean => {
const publicOnlyRoutePatterns = getPublicDomainRoutePatterns();
const isPublicRoute = matchesAnyPattern(url, publicOnlyRoutePatterns);
if (isPublicRoute) {
return false;
}
// For non-public routes, allow them (includes known admin routes and unknown routes like pipeline, cron)
return true;
};
/**
* Determine if a request should be allowed based on domain and route
*/
export const isRouteAllowedForDomain = (url: string, isPublicDomain: boolean): boolean => {
if (isPublicDomain) {
return isPublicDomainRoute(url);
}
return isAdminDomainRoute(url);
};

View File

@@ -0,0 +1,54 @@
/**
* Routes that should be accessible on the public domain (PUBLIC_URL)
* Uses whitelist approach - only these routes are allowed on public domain
*/
const PUBLIC_ROUTES = {
// Survey routes
SURVEY_ROUTES: [
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
],
// API routes accessible from public domain
API_ROUTES: [
/^\/api\/v[12]\/client\//, // /api/v1/client/** and /api/v2/client/**
],
// Share routes
SHARE_ROUTES: [
/^\/share\//, // /share/** - shared survey results
],
} as const;
const COMMON_ROUTES = {
HEALTH_ROUTES: [/^\/health$/], // /health endpoint
PUBLIC_STORAGE_ROUTES: [
/^\/storage\/[^/]+\/public\//, // /storage/[environmentId]/public/** - public storage
],
} as const;
/**
* Get public only route patterns as a flat array
*/
export const getPublicDomainRoutePatterns = (): RegExp[] => {
return Object.values(PUBLIC_ROUTES).flat();
};
/**
* Get all public route patterns as a flat array
*/
export const getAllPubliclyAccessibleRoutePatterns = (): RegExp[] => {
const routes = {
...PUBLIC_ROUTES,
...COMMON_ROUTES,
};
return Object.values(routes).flat();
};
/**
* Check if a URL matches any of the given route patterns
*/
export const matchesAnyPattern = (url: string, patterns: RegExp[]): boolean => {
return patterns.some((pattern) => pattern.test(url));
};

View File

@@ -3,12 +3,12 @@ import { cleanup } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import Page from "./page";
// Mock dependencies
vi.mock("@/lib/environment/service", () => ({
getFirstEnvironmentIdByUserId: vi.fn(),
vi.mock("@/lib/project/service", () => ({
getUserProjectEnvironmentsByOrganizationIds: vi.fn(),
}));
vi.mock("@/lib/instance/service", () => ({
@@ -48,8 +48,11 @@ vi.mock("@/modules/ui/components/client-logout", () => ({
}));
vi.mock("@/app/ClientEnvironmentRedirect", () => ({
default: ({ environmentId }: { environmentId: string }) => (
<div data-testid="client-environment-redirect">Environment ID: {environmentId}</div>
default: ({ environmentId, userEnvironments }: { environmentId: string; userEnvironments?: string[] }) => (
<div data-testid="client-environment-redirect">
Environment ID: {environmentId}
{userEnvironments && ` | User Environments: ${userEnvironments.join(", ")}`}
</div>
),
}));
@@ -149,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 { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/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");
@@ -204,13 +207,23 @@ describe("Page", () => {
role: "owner",
};
const mockUserProjects = [
{
id: "test-project-id",
name: "Test Project",
environments: [],
},
];
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "test-user-id" },
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(
mockUserProjects as unknown as TProject[]
);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isManager: false,
@@ -228,8 +241,8 @@ describe("Page", () => {
const { getServerSession } = await import("next-auth");
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
const { redirect } = await import("next/navigation");
@@ -284,13 +297,23 @@ describe("Page", () => {
role: "member",
};
const mockUserProjects = [
{
id: "test-project-id",
name: "Test Project",
environments: [],
},
];
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "test-user-id" },
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(
mockUserProjects as unknown as TProject[]
);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isManager: false,
@@ -309,9 +332,9 @@ describe("Page", () => {
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { render } = await import("@testing-library/react");
const mockUser: TUser = {
@@ -364,7 +387,43 @@ describe("Page", () => {
role: "member",
};
const mockEnvironmentId = "test-env-id";
const mockUserProjects = [
{
id: "project-1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "test-org-id",
styling: { allowStyleOverwrite: true },
recontactDays: 0,
inAppSurveyBranding: false,
linkSurveyBranding: false,
config: { channel: "link" as const, industry: "saas" as const },
placement: "bottomRight" as const,
clickOutsideClose: false,
darkOverlay: false,
languages: [],
logo: null,
environments: [
{
id: "test-env-id",
type: "production" as const,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-1",
appSetupCompleted: true,
},
{
id: "test-env-dev",
type: "development" as const,
createdAt: new Date(),
updatedAt: new Date(),
projectId: "project-1",
appSetupCompleted: true,
},
],
},
] as any;
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "test-user-id" },
@@ -372,8 +431,8 @@ describe("Page", () => {
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(mockEnvironmentId);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects);
vi.mocked(getAccessFlags).mockReturnValue({
isManager: false,
isOwner: false,
@@ -385,7 +444,7 @@ describe("Page", () => {
const { container } = render(result);
expect(container.querySelector('[data-testid="client-environment-redirect"]')).toHaveTextContent(
`Environment ID: ${mockEnvironmentId}`
`User Environments: test-env-id, test-env-dev`
);
});
});

View File

@@ -1,9 +1,9 @@
import ClientEnvironmentRedirect from "@/app/ClientEnvironmentRedirect";
import { getFirstEnvironmentIdByUserId } from "@/lib/environment/service";
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 { 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,16 +34,37 @@ const Page = async () => {
return redirect("/setup/organization/create");
}
let environmentId: string | null = null;
environmentId = await getFirstEnvironmentIdByUserId(session.user.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);
// Find first production environment and collect all other environment IDs in one pass
const { firstProductionEnvironmentId, otherEnvironmentIds } = allEnvironments.reduce(
(acc, env) => {
if (env.type === "production" && !acc.firstProductionEnvironmentId) {
acc.firstProductionEnvironmentId = env.id;
} else {
acc.otherEnvironmentIds.add(env.id);
}
return acc;
},
{ firstProductionEnvironmentId: null as string | null, otherEnvironmentIds: new Set<string>() }
);
const userEnvironments = [...otherEnvironmentIds];
const currentUserMembership = await getMembershipByUserIdOrganizationId(
session.user.id,
userOrganizations[0].id
);
const { isManager, isOwner } = getAccessFlags(currentUserMembership?.role);
if (!environmentId) {
if (!firstProductionEnvironmentId) {
if (isOwner || isManager) {
return redirect(`/organizations/${userOrganizations[0].id}/projects/new/mode`);
} else {
@@ -51,7 +72,10 @@ const Page = async () => {
}
}
return <ClientEnvironmentRedirect environmentId={environmentId} />;
// Put the first production environment at the front of the array
const sortedUserEnvironments = [firstProductionEnvironmentId, ...userEnvironments];
return <ClientEnvironmentRedirect userEnvironments={sortedUserEnvironments} />;
};
export default Page;

View File

@@ -1,3 +1,5 @@
import { LinkSurveyNotFound } from "@/modules/survey/link/not-found";
export default LinkSurveyNotFound;
export default function NotFound() {
return <LinkSurveyNotFound />;
}

View File

@@ -1,7 +1,8 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
@@ -46,6 +47,7 @@ const Page = async (props: ResponsesPageProps) => {
}
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
return (
<div className="flex w-full justify-center">
@@ -57,7 +59,7 @@ const Page = async (props: ResponsesPageProps) => {
environment={environment}
survey={survey}
surveyId={surveyId}
webAppUrl={WEBAPP_URL}
publicDomain={publicDomain}
environmentTags={tags}
responsesPerPage={RESPONSES_PER_PAGE}
locale={locale}

View File

@@ -1,8 +1,9 @@
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -50,6 +51,8 @@ const Page = async (props: SummaryPageProps) => {
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
const publicDomain = getPublicDomain();
return (
<div className="flex w-full justify-center">
<PageContentWrapper className="w-full">
@@ -60,7 +63,7 @@ const Page = async (props: SummaryPageProps) => {
environment={environment}
survey={survey}
surveyId={survey.id}
webAppUrl={WEBAPP_URL}
publicDomain={publicDomain}
isReadOnly={true}
locale={DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}

View File

@@ -13,7 +13,8 @@ import {
ZIntegrationAirtableTokenSchema,
} from "@formbricks/types/integration/airtable";
import { AIRTABLE_CLIENT_ID, AIRTABLE_MESSAGE_LIMIT } from "../constants";
import { createOrUpdateIntegration, deleteIntegration, getIntegrationByType } from "../integration/service";
import { createOrUpdateIntegration, getIntegrationByType } from "../integration/service";
import { delay } from "../utils/promises";
import { truncateText } from "../utils/strings";
export const getBases = async (key: string) => {
@@ -99,7 +100,11 @@ export const getAirtableToken = async (environmentId: string) => {
});
if (!newToken) {
throw new Error("Failed to create new token");
logger.error("Failed to fetch new Airtable token", {
environmentId,
airtableIntegration,
});
throw new Error("Failed to fetch new Airtable token");
}
await createOrUpdateIntegration(environmentId, {
@@ -116,9 +121,11 @@ export const getAirtableToken = async (environmentId: string) => {
return access_token;
} catch (error) {
await deleteIntegration(environmentId);
throw new Error("invalid token");
logger.error("Failed to get Airtable token", {
environmentId,
error,
});
throw new Error("Failed to get Airtable token");
}
};
@@ -178,6 +185,18 @@ const addField = async (
return await req.json();
};
const getExistingFields = async (key: TIntegrationAirtableCredential, baseId: string, tableId: string) => {
const req = await tableFetcher(key, baseId);
const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables;
const currentTable = tables.find((t) => t.id === tableId);
if (!currentTable) {
throw new Error(`Table with ID ${tableId} not found`);
}
return new Set(currentTable.fields.map((f) => f.name));
};
export const writeData = async (
key: TIntegrationAirtableCredential,
configData: TIntegrationAirtableConfigData,
@@ -186,6 +205,7 @@ export const writeData = async (
const responses = values[0];
const questions = values[1];
// 1) Build the record payload
const data: Record<string, string> = {};
for (let i = 0; i < questions.length; i++) {
data[questions[i]] =
@@ -194,34 +214,73 @@ export const writeData = async (
: responses[i];
}
const req = await tableFetcher(key, configData.baseId);
const tables = ZIntegrationAirtableTablesWithFields.parse(req).tables;
// 2) Figure out which fields need creating
const existingFields = await getExistingFields(key, configData.baseId, configData.tableId);
const fieldsToCreate = questions.filter((q) => !existingFields.has(q));
const currentTable = tables.find((table) => table.id === configData.tableId);
if (currentTable) {
const currentFields = new Set(currentTable.fields.map((field) => field.name));
const fieldsToCreate = new Set<string>();
for (const field of questions) {
const hasField = currentFields.has(field);
if (!hasField) {
fieldsToCreate.add(field);
// 3) Create any missing fields with throttling to respect Airtable's 5 req/sec per base limit
if (fieldsToCreate.length > 0) {
// Sequential processing with delays
const DELAY_BETWEEN_REQUESTS = 250; // 250ms = 4 requests per second (staying under 5/sec limit)
for (let i = 0; i < fieldsToCreate.length; i++) {
const fieldName = fieldsToCreate[i];
const createRes = await addField(key, configData.baseId, configData.tableId, {
name: fieldName,
type: "singleLineText",
});
if (createRes?.error) {
throw new Error(`Failed to create field "${fieldName}": ${JSON.stringify(createRes)}`);
}
// Add delay between requests (except for the last one)
if (i < fieldsToCreate.length - 1) {
await delay(DELAY_BETWEEN_REQUESTS);
}
}
if (fieldsToCreate.size > 0) {
const createFieldPromise: Promise<any>[] = [];
fieldsToCreate.forEach((fieldName) => {
createFieldPromise.push(
addField(key, configData.baseId, configData.tableId, {
name: fieldName,
type: "singleLineText",
})
);
});
// 4) Wait for the new fields to show up
await waitForFieldsToExist(key, configData, fieldsToCreate);
}
await Promise.all(createFieldPromise);
// 5) Finally, add the records
await addRecords(key, configData.baseId, configData.tableId, data);
};
async function waitForFieldsToExist(
key: TIntegrationAirtableCredential,
configData: TIntegrationAirtableConfigData,
fieldNames: string[],
maxRetries = 5,
intervalMs = 2000
) {
let existingFields: Set<string> = new Set(),
missingFields: string[] = [];
for (let attempt = 1; attempt <= maxRetries; attempt++) {
existingFields = await getExistingFields(key, configData.baseId, configData.tableId);
missingFields = fieldNames.filter((f) => !existingFields.has(f));
if (missingFields.length === 0) {
return;
}
if (attempt < maxRetries) {
logger.error(
`Attempt ${attempt}/${maxRetries}: ${missingFields.length} field(s) still missing [${missingFields.join(
", "
)}], retrying in ${intervalMs / 1000}s…`
);
await new Promise((r) => setTimeout(r, intervalMs));
}
}
await addRecords(key, configData.baseId, configData.tableId, data);
};
throw new Error(
`Timed out waiting for ${missingFields.length} field(s) [${missingFields.join(
", "
)}] to become available. Available fields: [${Array.from(existingFields).join(", ")}]`
);
}

View File

@@ -53,12 +53,11 @@ describe("Organization Access", () => {
test("hasOrganizationAccess should return true when user has membership", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "member",
createdAt: new Date(),
updatedAt: new Date(),
accepted: true,
deprecatedRole: null,
});
const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId);
@@ -74,12 +73,11 @@ describe("Organization Access", () => {
test("isManagerOrOwner should return true for manager role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
createdAt: new Date(),
updatedAt: new Date(),
accepted: true,
deprecatedRole: null,
});
const isManager = await isManagerOrOwner(mockUserId, mockOrgId);
@@ -88,12 +86,11 @@ describe("Organization Access", () => {
test("isManagerOrOwner should return true for owner role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "owner",
createdAt: new Date(),
updatedAt: new Date(),
accepted: true,
deprecatedRole: null,
});
const isOwner = await isManagerOrOwner(mockUserId, mockOrgId);
@@ -102,12 +99,11 @@ describe("Organization Access", () => {
test("isManagerOrOwner should return false for member role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "member",
createdAt: new Date(),
updatedAt: new Date(),
accepted: true,
deprecatedRole: null,
});
const isManagerOrOwnerRole = await isManagerOrOwner(mockUserId, mockOrgId);
@@ -116,12 +112,11 @@ describe("Organization Access", () => {
test("isOwner should return true only for owner role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "owner",
createdAt: new Date(),
updatedAt: new Date(),
accepted: true,
deprecatedRole: null,
});
const isOwnerRole = await isOwner(mockUserId, mockOrgId);
@@ -130,12 +125,11 @@ describe("Organization Access", () => {
test("isOwner should return false for non-owner roles", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
createdAt: new Date(),
updatedAt: new Date(),
accepted: true,
deprecatedRole: null,
});
const isOwnerRole = await isOwner(mockUserId, mockOrgId);
@@ -153,12 +147,11 @@ describe("Organization Authority", () => {
test("hasOrganizationAuthority should return true for manager", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
createdAt: new Date(),
updatedAt: new Date(),
accepted: true,
deprecatedRole: null,
});
const hasAuthority = await hasOrganizationAuthority(mockUserId, mockOrgId);
@@ -173,12 +166,11 @@ describe("Organization Authority", () => {
test("hasOrganizationAuthority should throw for member role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "member",
createdAt: new Date(),
updatedAt: new Date(),
accepted: true,
deprecatedRole: null,
});
await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
@@ -186,12 +178,11 @@ describe("Organization Authority", () => {
test("hasOrganizationOwnership should return true for owner", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "owner",
createdAt: new Date(),
updatedAt: new Date(),
accepted: true,
deprecatedRole: null,
});
const hasOwnership = await hasOrganizationOwnership(mockUserId, mockOrgId);
@@ -206,12 +197,11 @@ describe("Organization Authority", () => {
test("hasOrganizationOwnership should throw for non-owner roles", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
createdAt: new Date(),
updatedAt: new Date(),
accepted: true,
deprecatedRole: null,
});
await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);

View File

@@ -13,8 +13,6 @@ export const E2E_TESTING = env.E2E_TESTING === "1";
export const WEBAPP_URL =
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
export const SURVEY_URL = env.SURVEY_URL;
// encryption keys
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;

View File

@@ -85,7 +85,23 @@ export const env = createEnv({
SMTP_REJECT_UNAUTHORIZED_TLS: z.enum(["1", "0"]).optional(),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
SURVEY_URL: z.string().optional(),
PUBLIC_URL: z
.string()
.url()
.refine(
(url) => {
try {
const parsed = new URL(url);
return parsed.host && parsed.host.length > 0;
} catch {
return false;
}
},
{
message: "PUBLIC_URL must be a valid URL with a proper host (e.g., https://example.com)",
}
)
.optional(),
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
TERMS_URL: z
.string()
@@ -190,7 +206,7 @@ export const env = createEnv({
SMTP_AUTHENTICATED: process.env.SMTP_AUTHENTICATED,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
SURVEY_URL: process.env.SURVEY_URL,
PUBLIC_URL: process.env.PUBLIC_URL,
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
TURNSTILE_SECRET_KEY: process.env.TURNSTILE_SECRET_KEY,
TURNSTILE_SITE_KEY: process.env.TURNSTILE_SITE_KEY,

View File

@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
// Mock constants module
const envMock = {
env: {
WEBAPP_URL: "http://localhost:3000",
PUBLIC_URL: undefined as string | undefined,
},
};
vi.mock("@/lib/env", () => envMock);
describe("getPublicDomain", () => {
beforeEach(() => {
vi.resetModules();
});
test("should return WEBAPP_URL when PUBLIC_URL is not set", async () => {
const { getPublicDomain } = await import("./getPublicUrl");
const domain = getPublicDomain();
expect(domain).toBe("http://localhost:3000");
});
test("should return PUBLIC_URL when it is set", async () => {
envMock.env.PUBLIC_URL = "https://surveys.example.com";
const { getPublicDomain } = await import("./getPublicUrl");
const domain = getPublicDomain();
expect(domain).toBe("https://surveys.example.com");
});
test("should handle empty string PUBLIC_URL by returning WEBAPP_URL", async () => {
envMock.env.PUBLIC_URL = "";
const { getPublicDomain } = await import("./getPublicUrl");
const domain = getPublicDomain();
expect(domain).toBe("http://localhost:3000");
});
test("should handle undefined PUBLIC_URL by returning WEBAPP_URL", async () => {
envMock.env.PUBLIC_URL = undefined;
const { getPublicDomain } = await import("./getPublicUrl");
const domain = getPublicDomain();
expect(domain).toBe("http://localhost:3000");
});
});

View File

@@ -0,0 +1,13 @@
import "server-only";
import { env } from "./env";
const WEBAPP_URL =
env.WEBAPP_URL ?? (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : "") ?? "http://localhost:3000";
/**
* Returns the public domain URL
* Uses PUBLIC_URL if set, otherwise falls back to WEBAPP_URL
*/
export const getPublicDomain = (): string => {
return env.PUBLIC_URL && env.PUBLIC_URL.trim() !== "" ? env.PUBLIC_URL : WEBAPP_URL;
};

View File

@@ -1,46 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
// Create a mock module for constants with proper types
const constantsMock = {
SURVEY_URL: undefined as string | undefined,
WEBAPP_URL: "http://localhost:3000" as string,
};
// Mock the constants module
vi.mock("./constants", () => constantsMock);
describe("getSurveyDomain", () => {
beforeEach(() => {
// Reset the mock values before each test
constantsMock.SURVEY_URL = undefined;
constantsMock.WEBAPP_URL = "http://localhost:3000";
vi.resetModules();
});
test("should return WEBAPP_URL when SURVEY_URL is not set", async () => {
const { getSurveyDomain } = await import("./getSurveyUrl");
const domain = getSurveyDomain();
expect(domain).toBe("http://localhost:3000");
});
test("should return SURVEY_URL when it is set", async () => {
constantsMock.SURVEY_URL = "https://surveys.example.com";
const { getSurveyDomain } = await import("./getSurveyUrl");
const domain = getSurveyDomain();
expect(domain).toBe("https://surveys.example.com");
});
test("should handle empty string SURVEY_URL by returning WEBAPP_URL", async () => {
constantsMock.SURVEY_URL = "";
const { getSurveyDomain } = await import("./getSurveyUrl");
const domain = getSurveyDomain();
expect(domain).toBe("http://localhost:3000");
});
test("should handle undefined SURVEY_URL by returning WEBAPP_URL", async () => {
constantsMock.SURVEY_URL = undefined;
const { getSurveyDomain } = await import("./getSurveyUrl");
const domain = getSurveyDomain();
expect(domain).toBe("http://localhost:3000");
});
});

View File

@@ -1,10 +0,0 @@
import "server-only";
import { SURVEY_URL, WEBAPP_URL } from "./constants";
/**
* Returns the base URL for public surveys
* Uses SURVEY_URL if set, otherwise falls back to WEBAPP_URL
*/
export const getSurveyDomain = (): string => {
return SURVEY_URL || WEBAPP_URL;
};

View File

@@ -1,10 +1,16 @@
import { createId } from "@paralleldrive/cuid2";
import { Prisma } from "@prisma/client";
import { OrganizationRole, Prisma, WidgetPlacement } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import { getProject, getProjectByEnvironmentId, getProjects, getUserProjects } from "./service";
import {
getProject,
getProjectByEnvironmentId,
getProjects,
getUserProjectEnvironmentsByOrganizationIds,
getUserProjects,
} from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -15,6 +21,7 @@ vi.mock("@formbricks/database", () => ({
},
membership: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
},
}));
@@ -35,13 +42,20 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
};
vi.mocked(prisma.project.findUnique).mockResolvedValue(mockProject);
@@ -86,13 +100,20 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
};
vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject);
@@ -144,13 +165,20 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
{
id: createId(),
@@ -162,23 +190,29 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
];
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
id: createId(),
userId,
organizationId,
role: "admin",
createdAt: new Date(),
updatedAt: new Date(),
role: OrganizationRole.owner,
accepted: true,
deprecatedRole: null,
});
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
@@ -210,23 +244,29 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
];
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
id: createId(),
userId,
organizationId,
role: "member",
createdAt: new Date(),
updatedAt: new Date(),
role: OrganizationRole.member,
accepted: true,
deprecatedRole: null,
});
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
@@ -278,23 +318,29 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
];
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
id: createId(),
userId,
organizationId,
role: "admin",
createdAt: new Date(),
updatedAt: new Date(),
role: OrganizationRole.owner,
accepted: true,
deprecatedRole: null,
});
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
@@ -326,13 +372,20 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
{
id: createId(),
@@ -344,13 +397,20 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
];
@@ -382,13 +442,20 @@ describe("Project Service", () => {
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
config: {
channel: null,
industry: null,
},
placement: WidgetPlacement.bottomRight,
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
styling: {
allowStyleOverwrite: true,
},
logo: null,
brandColor: null,
highlightBorderColor: null,
},
];
@@ -418,4 +485,199 @@ describe("Project Service", () => {
await expect(getProjects(organizationId)).rejects.toThrow(DatabaseError);
});
test("getProjectsByOrganizationIds should return projects for given organization IDs", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
const mockProjects = [
{
environments: [],
},
{
environments: [],
},
];
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 getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }],
},
select: { environments: true },
});
});
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 getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual([]);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }],
},
select: { environments: true },
});
});
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(
getUserProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2], userId)
).rejects.toThrow(DatabaseError);
});
test("getProjectsByOrganizationIds should throw ValidationError with wrong input", async () => {
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

@@ -170,3 +170,67 @@ export const getOrganizationProjectsCount = reactCache(async (organizationId: st
throw error;
}
});
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 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 },
});
return projects;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(err.message);
}
throw err;
}
}
);

View File

@@ -15,6 +15,15 @@ vi.mock("@aws-sdk/client-s3", () => ({
GetObjectCommand: vi.fn(),
}));
vi.mock("@aws-sdk/s3-presigned-post", () => ({
createPresignedPost: vi.fn(() =>
Promise.resolve({
url: "https://test-bucket.s3.test-region.amazonaws.com",
fields: { key: "test-key", policy: "test-policy" },
})
),
}));
// Mock environment variables
vi.mock("../constants", () => ({
S3_ACCESS_KEY: "test-access-key",
@@ -34,11 +43,32 @@ vi.mock("../constants", () => ({
UPLOADS_DIR: "/tmp/uploads",
}));
// Mock getPublicDomain
vi.mock("../getPublicUrl", () => ({
getPublicDomain: () => "https://public-domain.com",
}));
// Mock crypto functions
vi.mock("crypto", () => ({
randomUUID: () => "test-uuid",
}));
// Mock local signed url generation
vi.mock("../crypto", () => ({
generateLocalSignedUrl: () => ({
signature: "test-signature",
timestamp: 123456789,
uuid: "test-uuid",
}),
}));
// Mock env
vi.mock("../env", () => ({
env: {
S3_BUCKET_NAME: "test-bucket",
},
}));
describe("Storage Service", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -131,4 +161,38 @@ describe("Storage Service", () => {
await expect(putFile(fileName, fileBuffer, accessType, environmentId)).rejects.toThrow("Upload failed");
});
});
describe("getUploadSignedUrl", () => {
let getUploadSignedUrl: any;
beforeEach(async () => {
const serviceModule = await import("./service");
getUploadSignedUrl = serviceModule.getUploadSignedUrl;
});
test("should use PUBLIC_URL for public files with S3", async () => {
const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "public");
expect(result.fileUrl).toContain("https://public-domain.com");
expect(result.fileUrl).toMatch(
/https:\/\/public-domain\.com\/storage\/env123\/public\/test--fid--test-uuid\.jpg/
);
});
test("should use WEBAPP_URL for private files with S3", async () => {
const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "private");
expect(result.fileUrl).toContain("http://test-webapp");
expect(result.fileUrl).toMatch(
/http:\/\/test-webapp\/storage\/env123\/private\/test--fid--test-uuid\.jpg/
);
});
test("should contain signed URL and presigned fields for S3", async () => {
const result = await getUploadSignedUrl("test.jpg", "env123", "image/jpeg", "public");
expect(result.signedUrl).toBe("https://test-bucket.s3.test-region.amazonaws.com");
expect(result.presignedFields).toEqual({ key: "test-key", policy: "test-policy" });
});
});
});

View File

@@ -31,6 +31,7 @@ import {
} from "../constants";
import { generateLocalSignedUrl } from "../crypto";
import { env } from "../env";
import { getPublicDomain } from "../getPublicUrl";
// S3Client Singleton
let s3ClientInstance: S3Client | null = null;
@@ -165,6 +166,10 @@ export const getUploadSignedUrl = async (
const updatedFileName = `${fileNameWithoutExtension}--fid--${randomUUID()}.${fileExtension}`;
// Use PUBLIC_URL for public files, WEBAPP_URL for private files
const publicDomain = getPublicDomain();
const baseUrl = accessType === "public" ? getPublicDomain() : WEBAPP_URL;
// handle the local storage case first
if (!isS3Configured()) {
try {
@@ -173,7 +178,7 @@ export const getUploadSignedUrl = async (
return {
signedUrl:
accessType === "private"
? new URL(`${WEBAPP_URL}/api/v1/client/${environmentId}/storage/local`).href
? new URL(`${publicDomain}/api/v1/client/${environmentId}/storage/local`).href
: new URL(`${WEBAPP_URL}/api/v1/management/storage/local`).href,
signingData: {
signature,
@@ -181,7 +186,7 @@ export const getUploadSignedUrl = async (
uuid,
},
updatedFileName,
fileUrl: new URL(`${WEBAPP_URL}/storage/${environmentId}/${accessType}/${updatedFileName}`).href,
fileUrl: new URL(`${baseUrl}/storage/${environmentId}/${accessType}/${updatedFileName}`).href,
};
} catch (err) {
throw err;
@@ -200,7 +205,7 @@ export const getUploadSignedUrl = async (
return {
signedUrl,
presignedFields,
fileUrl: new URL(`${WEBAPP_URL}/storage/${environmentId}/${accessType}/${updatedFileName}`).href,
fileUrl: new URL(`${baseUrl}/storage/${environmentId}/${accessType}/${updatedFileName}`).href,
};
} catch (err) {
throw err;

View File

@@ -197,13 +197,15 @@ export const getFallbackValues = (text: string): fallbacks => {
// Transforms headlines in a text to their corresponding recall information.
export const headlineToRecall = (
text: string,
text: string | undefined,
recallItems: TSurveyRecallItem[],
fallbacks: fallbacks
): string => {
if (!text) return "";
recallItems.forEach((recallItem) => {
const recallInfo = `#recall:${recallItem.id}/fallback:${fallbacks[recallItem.id]}#`;
text = text.replace(`@${recallItem.label}`, recallInfo);
text = text?.replace(`@${recallItem.label}`, recallInfo);
});
return text;
};

View File

@@ -79,7 +79,7 @@
},
"signup_without_verification_success": {
"user_successfully_created": "Benutzer erfolgreich erstellt",
"user_successfully_created_description": "Dein neuer Benutzer wurde erfolgreich erstellt. Bitte klicke auf den untenstehenden Button und melde Dich in deinem Konto an."
"user_successfully_created_info": "Wir haben nach einem Konto gesucht, das mit {email} verknüpft ist. Wenn keines existierte, haben wir eines für Dich erstellt. Wenn bereits ein Konto existierte, wurden keine Änderungen vorgenommen. Bitte melde Dich unten an, um fortzufahren."
},
"testimonial_1": "Als open-source Firma ist uns Datenschutz extrem wichtig! Formbricks bietet die perfekte Mischung aus modernster Technologie und solidem Datenschutz.",
"testimonial_all_features_included": "Alle Funktionen enthalten",
@@ -91,11 +91,10 @@
"invalid_token": "Ungültiges Token ☹️",
"new_email_verification_success": "Wenn die Adresse gültig ist, wurde eine Bestätigungs-E-Mail gesendet.",
"no_email_provided": "Keine E-Mail bereitgestellt",
"please_click_the_link_in_the_email_to_activate_your_account": "Bitte klicke auf den Link in der E-Mail, um dein Konto zu aktivieren.",
"please_confirm_your_email_address": "Bitte bestätige deine E-Mail-Adresse",
"resend_verification_email": "Bestätigungs-E-Mail erneut senden",
"verification_email_resent_successfully": "Bestätigungs-E-Mail gesendet! Bitte überprüfe dein Postfach.",
"we_sent_an_email_to": "Wir haben eine E-Mail an {email} gesendet",
"verification_email_successfully_sent_info": "Wenn ein Konto mit {email} verknüpft ist, haben wir einen Bestätigungslink an diese Adresse gesendet. Bitte überprüfe dein Postfach, um die Anmeldung abzuschließen.",
"you_didnt_receive_an_email_or_your_link_expired": "Hast Du keine E-Mail erhalten oder ist dein Link abgelaufen?"
},
"verify": {
@@ -310,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",
@@ -2651,6 +2649,7 @@
"professional_development_survey_description": "Bewerte die Zufriedenheit der Mitarbeiter mit beruflichen Entwicklungsmöglichkeiten.",
"professional_development_survey_name": "Berufliche Entwicklungsbewertung",
"professional_development_survey_question_1_choice_1": "Ja",
"professional_development_survey_question_1_choice_2": "Nein",
"professional_development_survey_question_1_headline": "Sind Sie an beruflichen Entwicklungsmöglichkeiten interessiert?",
"professional_development_survey_question_2_choice_1": "Networking-Veranstaltungen",
"professional_development_survey_question_2_choice_2": "Konferenzen oder Seminare",

View File

@@ -79,7 +79,7 @@
},
"signup_without_verification_success": {
"user_successfully_created": "User successfully created",
"user_successfully_created_description": "Your new user has been created successfully. Please click the button below and sign in to your account."
"user_successfully_created_info": "Weve checked for an account associated with {email}. If none existed, weve created one for you. If an account already existed, no changes were made. Please log in below to continue."
},
"testimonial_1": "We measure the clarity of our docs and learn from churn all on one platform. Great product, very responsive team!",
"testimonial_all_features_included": "All features included",
@@ -91,11 +91,10 @@
"invalid_token": "Invalid token ☹️",
"new_email_verification_success": "If the address is valid, a verification email has been sent.",
"no_email_provided": "No email provided",
"please_click_the_link_in_the_email_to_activate_your_account": "Please click the link in the email to activate your account.",
"please_confirm_your_email_address": "Please confirm your email address",
"resend_verification_email": "Resend verification email",
"verification_email_resent_successfully": "Verification email sent! Please check your inbox.",
"we_sent_an_email_to": "We sent an email to {email}. ",
"verification_email_successfully_sent_info": "If theres an account associated with {email}, weve sent a verification link to that address. Please check your inbox to complete the sign-up.",
"you_didnt_receive_an_email_or_your_link_expired": "You didn't receive an email or your link expired?"
},
"verify": {
@@ -310,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",
@@ -1893,12 +1891,12 @@
},
"s": {
"check_inbox_or_spam": "Please also check your spam folder if you don't see the email in your inbox.",
"completed": "This free & open-source survey has been closed.",
"create_your_own": "Create your own",
"completed": "This survey is closed.",
"create_your_own": "Create your own open-source survey",
"enter_pin": "This survey is protected. Enter the PIN below",
"just_curious": "Just curious?",
"link_invalid": "This survey can only be taken by invitation.",
"paused": "This free & open-source survey is temporarily paused.",
"paused": "This survey is temporarily paused.",
"please_try_again_with_the_original_link": "Please try again with the original link",
"preview_survey_questions": "Preview survey questions.",
"question_preview": "Question Preview",
@@ -2651,6 +2649,7 @@
"professional_development_survey_description": "Assess employee satisfaction with professional growth and development opportunities.",
"professional_development_survey_name": "Professional Development Survey",
"professional_development_survey_question_1_choice_1": "Yes",
"professional_development_survey_question_1_choice_2": "No",
"professional_development_survey_question_1_headline": "Are you interested in professional development activities?",
"professional_development_survey_question_2_choice_1": "Networking events",
"professional_development_survey_question_2_choice_2": "Conferences or seminars",

View File

@@ -79,7 +79,7 @@
},
"signup_without_verification_success": {
"user_successfully_created": "Utilisateur créé avec succès",
"user_successfully_created_description": "Votre nouvel utilisateur a été créé avec succès. Veuillez cliquer sur le bouton ci-dessous et vous connecter à votre compte."
"user_successfully_created_info": "Nous avons vérifié s'il existait un compte associé à {email}. Si aucun n'existait, nous en avons créé un pour vous. Si un compte existait déjà, aucune modification n'a été apportée. Veuillez vous connecter ci-dessous pour continuer."
},
"testimonial_1": "Nous mesurons la clarté de nos documents et apprenons des abandons, le tout sur une seule plateforme. Excellent produit, équipe très réactive !",
"testimonial_all_features_included": "Toutes les fonctionnalités incluses",
@@ -91,11 +91,10 @@
"invalid_token": "Jeton non valide ☹️",
"new_email_verification_success": "Si l'adresse est valide, un email de vérification a été envoyé.",
"no_email_provided": "Aucun e-mail fourni",
"please_click_the_link_in_the_email_to_activate_your_account": "Veuillez cliquer sur le lien dans l'e-mail pour activer votre compte.",
"please_confirm_your_email_address": "Veuillez confirmer votre adresse e-mail.",
"resend_verification_email": "Renvoyer l'email de vérification",
"verification_email_resent_successfully": "E-mail de vérification envoyé ! Veuillez vérifier votre boîte de réception.",
"we_sent_an_email_to": "Nous avons envoyé un email à {email}",
"verification_email_successfully_sent_info": "Si un compte est associé à {email}, nous avons envoyé un lien de vérification à cette adresse. Veuillez vérifier votre boîte de réception pour terminer l'inscription.",
"you_didnt_receive_an_email_or_your_link_expired": "Vous n'avez pas reçu d'email ou votre lien a expiré ?"
},
"verify": {
@@ -310,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",
@@ -2651,6 +2649,7 @@
"professional_development_survey_description": "Évaluer la satisfaction des employés concernant les opportunités de croissance et de développement professionnel.",
"professional_development_survey_name": "Sondage sur le développement professionnel",
"professional_development_survey_question_1_choice_1": "Oui",
"professional_development_survey_question_1_choice_2": "Non",
"professional_development_survey_question_1_headline": "Êtes-vous intéressé par des activités de développement professionnel ?",
"professional_development_survey_question_2_choice_1": "Événements de réseautage",
"professional_development_survey_question_2_choice_2": "Conférences ou séminaires",

View File

@@ -79,7 +79,7 @@
},
"signup_without_verification_success": {
"user_successfully_created": "Usuário criado com sucesso",
"user_successfully_created_description": "Seu novo usuário foi criado com sucesso. Por favor, clique no botão abaixo e faça login na sua conta."
"user_successfully_created_info": "Verificamos se há uma conta associada a {email}. Se não existia, criamos uma para você. Se uma conta já existia, nenhuma alteração foi feita. Por favor, faça login abaixo para continuar."
},
"testimonial_1": "Mediamos a clareza dos nossos documentos e aprendemos com a rotatividade tudo em uma única plataforma. Ótimo produto, equipe muito atenciosa!",
"testimonial_all_features_included": "Todas as funcionalidades incluídas",
@@ -91,11 +91,10 @@
"invalid_token": "Token inválido ☹️",
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
"no_email_provided": "Nenhum e-mail fornecido",
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clica no link do e-mail pra ativar sua conta.",
"please_confirm_your_email_address": "Por favor, confirme seu endereço de e-mail",
"resend_verification_email": "Reenviar e-mail de verificação",
"verification_email_resent_successfully": "E-mail de verificação enviado! Por favor, verifique sua caixa de entrada.",
"we_sent_an_email_to": "Enviamos um email para {email}",
"verification_email_successfully_sent_info": "Se houver uma conta associada a {email}, enviamos um link de verificação para esse endereço. Por favor, verifique sua caixa de entrada para completar o cadastro.",
"you_didnt_receive_an_email_or_your_link_expired": "Você não recebeu um e-mail ou seu link expirou?"
},
"verify": {
@@ -310,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",
@@ -358,7 +356,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",
@@ -370,7 +368,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",
@@ -2651,6 +2649,7 @@
"professional_development_survey_description": "Avalie a satisfação dos funcionários com oportunidades de desenvolvimento profissional.",
"professional_development_survey_name": "Avaliação de Desenvolvimento Profissional",
"professional_development_survey_question_1_choice_1": "Sim",
"professional_development_survey_question_1_choice_2": "Não",
"professional_development_survey_question_1_headline": "Você está interessado em atividades de desenvolvimento profissional?",
"professional_development_survey_question_2_choice_1": "Eventos de networking",
"professional_development_survey_question_2_choice_2": "Conferencias ou seminários",

View File

@@ -79,7 +79,7 @@
},
"signup_without_verification_success": {
"user_successfully_created": "Utilizador criado com sucesso",
"user_successfully_created_description": "O seu novo utilizador foi criado com sucesso. Por favor, clique no botão abaixo e inicie sessão na sua conta."
"user_successfully_created_info": "Verificámos a existência de uma conta associada a {email}. Se não existia, criámos uma para si. Se já existia uma conta, não foram feitas alterações. Por favor, inicie sessão abaixo para continuar."
},
"testimonial_1": "Medimos a clareza dos nossos documentos e aprendemos com a rotatividade, tudo numa só plataforma. Ótimo produto, equipa muito responsiva!",
"testimonial_all_features_included": "Todas as funcionalidades incluídas",
@@ -91,11 +91,10 @@
"invalid_token": "Token inválido ☹️",
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
"no_email_provided": "Nenhum email fornecido",
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clique no link no email para ativar a sua conta.",
"please_confirm_your_email_address": "Por favor, confirme o seu endereço de email",
"resend_verification_email": "Reenviar email de verificação",
"verification_email_resent_successfully": "Email de verificação enviado! Por favor, verifique a sua caixa de entrada.",
"we_sent_an_email_to": "Enviámos um email para {email}. ",
"verification_email_successfully_sent_info": "Se houver uma conta associada a {email}, enviámos um link de verificação para esse endereço. Por favor, verifique a sua caixa de entrada para completar o registo.",
"you_didnt_receive_an_email_or_your_link_expired": "Não recebeu um email ou o seu link expirou?"
},
"verify": {
@@ -310,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",
@@ -1893,12 +1891,12 @@
},
"s": {
"check_inbox_or_spam": "Por favor, verifique também a sua pasta de spam se não vir o email na sua caixa de entrada.",
"completed": "Este inquérito gratuito e de código aberto foi encerrado.",
"create_your_own": "Crie o seu próprio",
"completed": "Este inquérito está encerrado.",
"create_your_own": "Crie o seu próprio inquérito de código aberto",
"enter_pin": "Este inquérito está protegido. Introduza o PIN abaixo",
"just_curious": "Só por curiosidade?",
"link_invalid": "Este inquérito só pode ser respondido por convite.",
"paused": "Este inquérito gratuito e de código aberto está temporariamente pausado.",
"paused": "Este inquérito está temporariamente suspenso.",
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
"preview_survey_questions": "Pré-visualizar perguntas do inquérito.",
"question_preview": "Pré-visualização da Pergunta",
@@ -2651,6 +2649,7 @@
"professional_development_survey_description": "Avaliar a satisfação dos funcionários com as oportunidades de crescimento e desenvolvimento profissional.",
"professional_development_survey_name": "Inquérito de Desenvolvimento Profissional",
"professional_development_survey_question_1_choice_1": "Sim",
"professional_development_survey_question_1_choice_2": "Não",
"professional_development_survey_question_1_headline": "Está interessado em atividades de desenvolvimento profissional?",
"professional_development_survey_question_2_choice_1": "Eventos de networking",
"professional_development_survey_question_2_choice_2": "Conferências ou seminários",

View File

@@ -79,7 +79,7 @@
},
"signup_without_verification_success": {
"user_successfully_created": "使用者建立成功",
"user_successfully_created_description": "您的新使用者已成功建立。請點擊下方按鈕並登入您的帳戶。"
"user_successfully_created_info": "我們已檢查與 {email} 相關聯的帳戶。如果不存在,我們已為您建立一個。如果帳戶已存在,則未進行任何更改。請在下方登入以繼續。"
},
"testimonial_1": "我們在同一個平台上測量文件的清晰度,並從客戶流失中學習。很棒的產品,團隊反應非常迅速!",
"testimonial_all_features_included": "包含所有功能",
@@ -91,11 +91,10 @@
"invalid_token": "無效的權杖 ☹️",
"new_email_verification_success": "如果地址有效,驗證電子郵件已發送。",
"no_email_provided": "未提供電子郵件",
"please_click_the_link_in_the_email_to_activate_your_account": "請點擊電子郵件中的連結以啟用您的帳戶。",
"please_confirm_your_email_address": "請確認您的電子郵件地址",
"resend_verification_email": "重新發送驗證電子郵件",
"verification_email_resent_successfully": "驗證電子郵件已發送!請檢查您的收件箱。",
"we_sent_an_email_to": "我們已發送一封電子郵件至 <email>'{'email'}'</email>。",
"verification_email_successfully_sent_info": "如果有一個帳戶與 {email} 相關聯,我們已發送驗證連結至該地址。請檢查您的收件箱以完成註冊。",
"you_didnt_receive_an_email_or_your_link_expired": "您沒有收到電子郵件或您的連結已過期?"
},
"verify": {
@@ -310,7 +309,6 @@
"project_not_found": "找不到專案",
"project_permission_not_found": "找不到專案權限",
"projects": "專案",
"projects_limit_reached": "已達到專案上限",
"question": "問題",
"question_id": "問題 ID",
"questions": "問題",
@@ -2651,6 +2649,7 @@
"professional_development_survey_description": "評估員工對專業成長和發展機會的滿意度。",
"professional_development_survey_name": "專業發展問卷",
"professional_development_survey_question_1_choice_1": "是",
"professional_development_survey_question_1_choice_2": "否",
"professional_development_survey_question_1_headline": "您對專業發展活動感興趣嗎?",
"professional_development_survey_question_2_choice_1": "人脈交流活動",
"professional_development_survey_question_2_choice_2": "研討會或研討會",

View File

@@ -7,17 +7,19 @@ import {
syncUserIdentificationLimiter,
verifyEmailLimiter,
} from "@/app/middleware/bucket";
import { isPublicDomainConfigured, isRequestFromPublicDomain } from "@/app/middleware/domain-utils";
import {
isAuthProtectedRoute,
isClientSideApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isRouteAllowedForDomain,
isShareUrlRoute,
isSignupRoute,
isSyncWithUserIdentificationEndpoint,
isVerifyEmailRoute,
} from "@/app/middleware/endpoint-validator";
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, SURVEY_URL, WEBAPP_URL } from "@/lib/constants";
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@/lib/constants";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { isValidCallbackUrl } from "@/lib/utils/url";
import { logApiErrorEdge } from "@/modules/api/v2/lib/utils-edge";
@@ -69,33 +71,38 @@ const applyRateLimiting = async (request: NextRequest, ip: string) => {
}
};
const handleSurveyDomain = (request: NextRequest): Response | null => {
/**
* Handle domain-aware routing based on PUBLIC_URL and WEBAPP_URL
*/
const handleDomainAwareRouting = (request: NextRequest): Response | null => {
try {
if (!SURVEY_URL) return null;
const publicDomainConfigured = isPublicDomainConfigured();
const host = request.headers.get("host") || "";
const surveyDomain = SURVEY_URL ? new URL(SURVEY_URL).host : "";
if (host !== surveyDomain) return null;
// When PUBLIC_URL is not configured, admin domain allows all routes (backward compatibility)
if (!publicDomainConfigured) return null;
return new NextResponse(null, { status: 404 });
const isPublicDomain = isRequestFromPublicDomain(request);
const pathname = request.nextUrl.pathname;
// Check if the route is allowed for the current domain
const isAllowed = isRouteAllowedForDomain(pathname, isPublicDomain);
if (!isAllowed) {
return new NextResponse(null, { status: 404 });
}
return null; // Allow the request to continue
} catch (error) {
logger.error(error, "Error handling survey domain");
logger.error(error, "Error handling domain-aware routing");
return new NextResponse(null, { status: 404 });
}
};
const isSurveyRoute = (request: NextRequest) => {
return request.nextUrl.pathname.startsWith("/c/") || request.nextUrl.pathname.startsWith("/s/");
};
export const middleware = async (originalRequest: NextRequest) => {
if (isSurveyRoute(originalRequest)) {
return NextResponse.next();
}
// Handle survey domain routing.
const surveyResponse = handleSurveyDomain(originalRequest);
if (surveyResponse) return surveyResponse;
// Handle domain-aware routing first
const domainResponse = handleDomainAwareRouting(originalRequest);
if (domainResponse) return domainResponse;
// Create a new Request object to override headers and add a unique request ID header
const request = new NextRequest(originalRequest, {
@@ -142,6 +149,6 @@ export const middleware = async (originalRequest: NextRequest) => {
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public|api/v1/og).*)", // Exclude the Open Graph image generation route from middleware
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public).*)",
],
};

View File

@@ -1,3 +1,4 @@
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";
@@ -78,6 +79,11 @@ describe("DeleteAccountModal", () => {
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
Object.defineProperty(window, "localStorage", {
writable: true,
value: { removeItem: vi.fn() },
});
// Mock window.location.replace
Object.defineProperty(window, "location", {
writable: true,
@@ -94,6 +100,8 @@ describe("DeleteAccountModal", () => {
/>
);
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } });
@@ -106,6 +114,7 @@ describe("DeleteAccountModal", () => {
reason: "account_deletion",
redirect: false, // Updated to match new implementation
});
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(window.location.replace).toHaveBeenCalledWith("/auth/login");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
@@ -116,6 +125,11 @@ describe("DeleteAccountModal", () => {
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
Object.defineProperty(window, "localStorage", {
writable: true,
value: { removeItem: vi.fn() },
});
Object.defineProperty(window, "location", {
writable: true,
value: { replace: vi.fn() },
@@ -137,12 +151,15 @@ 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
});
expect(removeItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(window.location.replace).toHaveBeenCalledWith(
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2"
);

View File

@@ -1,5 +1,6 @@
"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";
@@ -38,6 +39,8 @@ 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",

View File

@@ -61,20 +61,20 @@ describe("RatingSmiley", () => {
cleanup();
});
const activeClass = "fill-rating-fill";
const activeClass = "bg-rating-fill";
// Test branch: range === 10 => iconsIdx = [0,1,2,...,9]
test("renders correct icon for range 10 when active", () => {
// For idx 0, iconsIdx[0] === 0, which corresponds to TiredFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={10} addColors={true} />);
const icon = getByTestId("TiredFace");
const icon = getByTestId("tired");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
test("renders correct icon for range 10 when inactive", () => {
const { getByTestId } = render(<RatingSmiley active={false} idx={0} range={10} />);
const icon = getByTestId("TiredFace");
const icon = getByTestId("tired");
expect(icon).toBeDefined();
expect(icon.className).toContain("fill-none");
});
@@ -83,7 +83,7 @@ describe("RatingSmiley", () => {
test("renders correct icon for range 7 when active", () => {
// For idx 0, iconsIdx[0] === 1, which corresponds to WearyFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={7} addColors={true} />);
const icon = getByTestId("WearyFace");
const icon = getByTestId("weary");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
@@ -92,7 +92,7 @@ describe("RatingSmiley", () => {
test("renders correct icon for range 5 when active", () => {
// For idx 0, iconsIdx[0] === 3, which corresponds to FrowningFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={5} addColors={true} />);
const icon = getByTestId("FrowningFace");
const icon = getByTestId("frowning");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
@@ -101,7 +101,7 @@ describe("RatingSmiley", () => {
test("renders correct icon for range 4 when active", () => {
// For idx 0, iconsIdx[0] === 4, corresponding to ConfusedFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={4} addColors={true} />);
const icon = getByTestId("ConfusedFace");
const icon = getByTestId("confused");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
@@ -110,7 +110,7 @@ describe("RatingSmiley", () => {
test("renders correct icon for range 3 when active", () => {
// For idx 0, iconsIdx[0] === 4, corresponding to ConfusedFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={3} addColors={true} />);
const icon = getByTestId("ConfusedFace");
const icon = getByTestId("confused");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});

View File

@@ -1,93 +1,89 @@
import type { JSX } from "react";
import {
ConfusedFace,
FrowningFace,
GrinningFaceWithSmilingEyes,
GrinningSquintingFace,
NeutralFace,
PerseveringFace,
SlightlySmilingFace,
SmilingFaceWithSmilingEyes,
TiredFace,
WearyFace,
} from "../SingleResponseCard/components/Smileys";
interface RatingSmileyProps {
active: boolean;
idx: number;
range: number;
addColors?: boolean;
baseUrl?: string;
}
const getSmileyColor = (range: number, idx: number) => {
if (range > 5) {
if (range - idx < 3) return "fill-emerald-100";
if (range - idx < 5) return "fill-orange-100";
return "fill-rose-100";
if (range - idx < 3) return "bg-emerald-100";
if (range - idx < 5) return "bg-orange-100";
return "bg-rose-100";
} else if (range < 5) {
if (range - idx < 2) return "fill-emerald-100";
if (range - idx < 3) return "fill-orange-100";
return "fill-rose-100";
if (range - idx < 2) return "bg-emerald-100";
if (range - idx < 3) return "bg-orange-100";
return "bg-rose-100";
} else {
if (range - idx < 3) return "fill-emerald-100";
if (range - idx < 4) return "fill-orange-100";
return "fill-rose-100";
if (range - idx < 3) return "bg-emerald-100";
if (range - idx < 4) return "bg-orange-100";
return "bg-rose-100";
}
};
const getSmiley = (iconIdx: number, idx: number, range: number, active: boolean, addColors: boolean) => {
const activeColor = "fill-rating-fill";
const inactiveColor = addColors ? getSmileyColor(range, idx) : "fill-none";
// Helper function to get smiley image URL based on index and range
const getSmiley = (
iconIdx: number,
idx: number,
range: number,
active: boolean,
addColors: boolean,
baseUrl?: string
): JSX.Element => {
const activeColor = "bg-rating-fill";
const inactiveColor = addColors ? getSmileyColor(range, idx) : "bg-fill-none";
const icons = [
<TiredFace className={active ? activeColor : inactiveColor} data-testid="TiredFace" key="tired-face" />,
<WearyFace className={active ? activeColor : inactiveColor} data-testid="WearyFace" key="weary-face" />,
<PerseveringFace
className={active ? activeColor : inactiveColor}
data-testid="PerseveringFace"
key="perserving-face"
/>,
<FrowningFace
className={active ? activeColor : inactiveColor}
data-testid="FrowningFace"
key="frowning-face"
/>,
<ConfusedFace
className={active ? activeColor : inactiveColor}
data-testid="ConfusedFace"
key="confused-face"
/>,
<NeutralFace
className={active ? activeColor : inactiveColor}
data-testid="NeutralFace"
key="neutral-face"
/>,
<SlightlySmilingFace
className={active ? activeColor : inactiveColor}
data-testid="SlightlySmilingFace"
key="slightly-smiling-face"
/>,
<SmilingFaceWithSmilingEyes
className={active ? activeColor : inactiveColor}
data-testid="SmilingFaceWithSmilingEyes"
key="smiling-face-with-smiling-eyes"
/>,
<GrinningFaceWithSmilingEyes
className={active ? activeColor : inactiveColor}
data-testid="GrinningFaceWithSmilingEyes"
key="grinning-face-with-smiling-eyes"
/>,
<GrinningSquintingFace
className={active ? activeColor : inactiveColor}
data-testid="GrinningSquintingFace"
key="grinning-squinting-face"
/>,
const faceIcons = [
"tired",
"weary",
"persevering",
"frowning",
"confused",
"neutral",
"slightly-smiling",
"smiling-face-with-smiling-eyes",
"grinning-face-with-smiling-eyes",
"grinning-squinting",
];
return icons[iconIdx];
const icon = (
<img
data-testid={faceIcons[iconIdx]}
src={
baseUrl
? `${baseUrl}/smiley-icons/${faceIcons[iconIdx]}-face.png`
: `/smiley-icons/${faceIcons[iconIdx]}-face.png`
}
alt={faceIcons[iconIdx]}
width={24}
height={24}
className={`${active ? activeColor : inactiveColor} rounded-full`}
/>
);
return (
<table style={{ width: "48px", height: "48px" }}>
{" "}
{/* NOSONAR S5256 - Need table layout for email compatibility (gmail) */}
<tr>
<td align="center" valign="middle">
{icon}
</td>
</tr>
</table>
);
};
export const RatingSmiley = ({ active, idx, range, addColors = false }: RatingSmileyProps): JSX.Element => {
export const RatingSmiley = ({
active,
idx,
range,
addColors = false,
baseUrl,
}: RatingSmileyProps): JSX.Element => {
let iconsIdx: number[] = [];
if (range === 10) iconsIdx = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
else if (range === 7) iconsIdx = [1, 3, 4, 5, 6, 8, 9];
@@ -96,5 +92,5 @@ export const RatingSmiley = ({ active, idx, range, addColors = false }: RatingSm
else if (range === 4) iconsIdx = [4, 5, 6, 7];
else if (range === 3) iconsIdx = [4, 5, 7];
return getSmiley(iconsIdx[idx], idx, range, active, addColors);
return getSmiley(iconsIdx[idx], idx, range, active, addColors, baseUrl);
};

View File

@@ -13,7 +13,7 @@ const dummySurvey = {
type: "link",
status: "completed",
} as any;
const dummySurveyDomain = "http://dummy.com";
const dummyPublicDomain = "http://dummy.com";
const dummyLocale = "en-US";
vi.mock("@/lib/constants", () => ({
@@ -93,7 +93,7 @@ describe("ShareSurveyLink", () => {
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
publicDomain={dummyPublicDomain}
surveyUrl=""
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
@@ -103,7 +103,7 @@ describe("ShareSurveyLink", () => {
expect(setSurveyUrl).toHaveBeenCalled();
});
const url = setSurveyUrl.mock.calls[0][0];
expect(url).toContain(`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`);
expect(url).toContain(`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`);
expect(url).not.toContain("lang=");
});
@@ -114,7 +114,7 @@ describe("ShareSurveyLink", () => {
const DummyWrapper = () => (
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
publicDomain={dummyPublicDomain}
surveyUrl="initial"
setSurveyUrl={setSurveyUrl}
locale="fr-FR"
@@ -130,12 +130,12 @@ describe("ShareSurveyLink", () => {
test("preview button opens new window with preview query", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn().mockReturnValue(`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`);
const setSurveyUrl = vi.fn().mockReturnValue(`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`);
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
publicDomain={dummyPublicDomain}
surveyUrl={`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
@@ -156,11 +156,11 @@ describe("ShareSurveyLink", () => {
vi.mocked(copySurveyLink).mockImplementation((url: string, newId: string) => `${url}?suId=${newId}`);
const setSurveyUrl = vi.fn();
const surveyUrl = `${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`;
const surveyUrl = `${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`;
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
publicDomain={dummyPublicDomain}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
@@ -185,8 +185,8 @@ describe("ShareSurveyLink", () => {
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
publicDomain={dummyPublicDomain}
surveyUrl={`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
@@ -205,8 +205,8 @@ describe("ShareSurveyLink", () => {
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
publicDomain={dummyPublicDomain}
surveyUrl={`${dummyPublicDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
@@ -227,7 +227,7 @@ describe("ShareSurveyLink", () => {
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
publicDomain={dummyPublicDomain}
surveyUrl=""
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}

View File

@@ -15,7 +15,7 @@ import { SurveyLinkDisplay } from "./components/SurveyLinkDisplay";
interface ShareSurveyLinkProps {
survey: TSurvey;
surveyDomain: string;
publicDomain: string;
surveyUrl: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
@@ -24,7 +24,7 @@ interface ShareSurveyLinkProps {
export const ShareSurveyLink = ({
survey,
surveyUrl,
surveyDomain,
publicDomain,
setSurveyUrl,
locale,
}: ShareSurveyLinkProps) => {
@@ -34,7 +34,7 @@ export const ShareSurveyLink = ({
useEffect(() => {
const fetchSurveyUrl = async () => {
try {
const url = await getSurveyUrl(survey, surveyDomain, language);
const url = await getSurveyUrl(survey, publicDomain, language);
setSurveyUrl(url);
} catch (error) {
const errorMessage = getFormattedErrorMessage(error);
@@ -42,11 +42,11 @@ export const ShareSurveyLink = ({
}
};
fetchSurveyUrl();
}, [survey, language, surveyDomain, setSurveyUrl]);
}, [survey, language, publicDomain, setSurveyUrl]);
const generateNewSingleUseLink = async () => {
try {
const newUrl = await getSurveyUrl(survey, surveyDomain, language);
const newUrl = await getSurveyUrl(survey, publicDomain, language);
setSurveyUrl(newUrl);
toast.success(t("environments.surveys.new_single_use_link_generated"));
} catch (error) {

View File

@@ -1,60 +0,0 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import {
ConfusedFace,
FrowningFace,
GrinningFaceWithSmilingEyes,
GrinningSquintingFace,
NeutralFace,
PerseveringFace,
SlightlySmilingFace,
SmilingFaceWithSmilingEyes,
TiredFace,
WearyFace,
} from "./Smileys";
const checkSvg = (Component: React.FC<React.SVGProps<SVGElement>>) => {
const { container } = render(<Component />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg).toHaveAttribute("viewBox", "0 0 72 72");
expect(svg).toHaveAttribute("width", "36");
expect(svg).toHaveAttribute("height", "36");
};
describe("Smileys", () => {
afterEach(() => {
cleanup();
});
test("renders TiredFace", () => {
checkSvg(TiredFace);
});
test("renders WearyFace", () => {
checkSvg(WearyFace);
});
test("renders PerseveringFace", () => {
checkSvg(PerseveringFace);
});
test("renders FrowningFace", () => {
checkSvg(FrowningFace);
});
test("renders ConfusedFace", () => {
checkSvg(ConfusedFace);
});
test("renders NeutralFace", () => {
checkSvg(NeutralFace);
});
test("renders SlightlySmilingFace", () => {
checkSvg(SlightlySmilingFace);
});
test("renders SmilingFaceWithSmilingEyes", () => {
checkSvg(SmilingFaceWithSmilingEyes);
});
test("renders GrinningFaceWithSmilingEyes", () => {
checkSvg(GrinningFaceWithSmilingEyes);
});
test("renders GrinningSquintingFace", () => {
checkSvg(GrinningSquintingFace);
});
});

View File

@@ -1,462 +0,0 @@
export const TiredFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m21.88 23.92c5.102-0.06134 7.273-1.882 8.383-3.346"
/>
<path
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
d="m46.24 47.56c0-2.592-2.867-7.121-10.25-6.93-6.974 0.1812-10.22 4.518-10.22 7.111s4.271-1.611 10.05-1.492c6.317 0.13 10.43 3.903 10.43 1.311z"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m23.16 28.47c5.215 1.438 5.603 0.9096 8.204 1.207 1.068 0.1221-2.03 2.67-7.282 4.397"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m50.12 23.92c-5.102-0.06134-7.273-1.882-8.383-3.346"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m48.84 28.47c-5.215 1.438-5.603 0.9096-8.204 1.207-1.068 0.1221 2.03 2.67 7.282 4.397"
/>
</g>
</svg>
);
};
export const WearyFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m22.88 23.92c5.102-0.06134 7.273-1.882 8.383-3.346"
/>
<path
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
d="m46.24 47.56c0-2.592-2.867-7.121-10.25-6.93-6.974 0.1812-10.22 4.518-10.22 7.111s4.271-1.611 10.05-1.492c6.317 0.13 10.43 3.903 10.43 1.311z"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m49.12 23.92c-5.102-0.06134-7.273-1.882-8.383-3.346"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m48.24 30.51c-6.199 1.47-7.079 1.059-8.868-1.961"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="m23.76 30.51c6.199 1.47 7.079 1.059 8.868-1.961"
/>
</g>
</svg>
);
};
export const PerseveringFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
{...props}
/>
<line
x1="44.5361"
x2="50.9214"
y1="21.4389"
y2="24.7158"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
/>
<line
x1="26.9214"
x2="20.5361"
y1="21.4389"
y2="24.7158"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M24,28c2.3334,1.3333,4.6666,2.6667,7,4c-2.3334,1.3333-4.6666,2.6667-7,4"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M48,28c-2.3334,1.3333-4.6666,2.6667-7,4c2.3334,1.3333,4.6666,2.6667,7,4"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M28,51c0.2704-0.3562,1-8,8.4211-8.0038C43,42.9929,43.6499,50.5372,44,51C38.6667,51,33.3333,51,28,51z"
/>
</g>
</svg>
);
};
export const FrowningFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M26.5,48c1.8768-3.8326,5.8239-6.1965,10-6c3.8343,0.1804,7.2926,2.4926,9,6"
/>
<path d="M30,31c0,1.6568-1.3448,3-3,3c-1.6553,0-3-1.3433-3-3c0-1.6552,1.3447-3,3-3C28.6552,28,30,29.3448,30,31" />
<path d="M48,31c0,1.6568-1.3447,3-3,3s-3-1.3433-3-3c0-1.6552,1.3447-3,3-3S48,29.3448,48,31" />
</g>
</svg>
);
};
export const ConfusedFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="m44.7 43.92c-6.328-1.736-11.41-0.906-17.4 1.902"
/>
<path d="M30,31c0,1.6568-1.3448,3-3,3c-1.6553,0-3-1.3433-3-3c0-1.6552,1.3447-3,3-3C28.6552,28,30,29.3448,30,31" />
<path d="M48,31c0,1.6568-1.3447,3-3,3s-3-1.3433-3-3c0-1.6552,1.3447-3,3-3S48,29.3448,48,31" />
</g>
</svg>
);
};
export const NeutralFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeMiterlimit="10"
strokeWidth="2"
{...props}
/>
<line
x1="27"
x2="45"
y1="43"
y2="43"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
strokeWidth="2"
/>
<path d="M30,31c0,1.6568-1.3448,3-3,3c-1.6553,0-3-1.3433-3-3c0-1.6552,1.3447-3,3-3C28.6552,28,30,29.3448,30,31" />
<path d="M48,31c0,1.6568-1.3447,3-3,3s-3-1.3433-3-3c0-1.6552,1.3447-3,3-3S48,29.3448,48,31" />
</g>
</svg>
);
};
export const SlightlySmilingFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M45.8149,44.9293 c-2.8995,1.6362-6.2482,2.5699-9.8149,2.5699s-6.9153-0.9336-9.8149-2.5699"
/>
<path d="M30,31c0,1.6568-1.3448,3-3,3c-1.6553,0-3-1.3433-3-3c0-1.6552,1.3447-3,3-3C28.6552,28,30,29.3448,30,31" />
<path d="M48,31c0,1.6568-1.3447,3-3,3s-3-1.3433-3-3c0-1.6552,1.3447-3,3-3S48,29.3448,48,31" />
</g>
</svg>
);
};
export const SmilingFaceWithSmilingEyes: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M45.8147,45.2268a15.4294,15.4294,0,0,1-19.6294,0"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M31.6941,33.4036a4.7262,4.7262,0,0,0-8.6382,0"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M48.9441,33.4036a4.7262,4.7262,0,0,0-8.6382,0"
/>
</g>
</svg>
);
};
export const GrinningFaceWithSmilingEyes: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
{...props}
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M50.595,41.64a11.5554,11.5554,0,0,1-.87,4.49c-12.49,3.03-25.43.34-27.49-.13a11.4347,11.4347,0,0,1-.83-4.36h.11s14.8,3.59,28.89.07Z"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M49.7251,46.13c-1.79,4.27-6.35,7.23-13.69,7.23-7.41,0-12.03-3.03-13.8-7.36C24.2951,46.47,37.235,49.16,49.7251,46.13Z"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M31.6941,32.4036a4.7262,4.7262,0,0,0-8.6382,0"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeMiterlimit="10"
strokeWidth="2"
d="M48.9441,32.4036a4.7262,4.7262,0,0,0-8.6382,0"
/>
</g>
</svg>
);
};
export const GrinningSquintingFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
return (
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
<g id="line">
<circle
cx="36"
cy="36"
r="23"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
{...props}
/>
<polyline
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
points="25.168 27.413 31.755 31.427 25.168 35.165"
/>
<polyline
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
points="46.832 27.413 40.245 31.427 46.832 35.165"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M50.595,41.64a11.5554,11.5554,0,0,1-.87,4.49c-12.49,3.03-25.43.34-27.49-.13a11.4347,11.4347,0,0,1-.83-4.36h.11s14.8,3.59,28.89.07Z"
/>
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M49.7251,46.13c-1.79,4.27-6.35,7.23-13.69,7.23-7.41,0-12.03-3.03-13.8-7.36C24.2951,46.47,37.235,49.16,49.7251,46.13Z"
/>
</g>
</svg>
);
};

View File

@@ -32,10 +32,10 @@ export const renderHyperlinkedContent = (data: string): JSX.Element[] => {
export const getSurveyUrl = async (
survey: TSurvey,
surveyDomain: string,
publicDomain: string,
language: string
): Promise<string> => {
let url = `${surveyDomain}/s/${survey.id}`;
let url = `${publicDomain}/s/${survey.id}`;
const queryParams: string[] = [];
if (survey.singleUse?.enabled) {

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

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

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