Compare commits

..

68 Commits

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

@@ -3,4 +3,5 @@ description: Whenever the user asks to write or update a test file for .tsx or .
globs:
alwaysApply: false
---
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md).
After writing the tests, run them and check if there's any issue with the tests and if all of them are passing. Fix the issues and rerun the tests until all pass.

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 #
@@ -190,7 +190,7 @@ UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
# REDIS_URL=redis://localhost:6379
REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
@@ -216,3 +216,8 @@ UNKEY_ROOT_KEY=
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
# AUDIT_LOG_ENABLED=0
# If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0

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

View File

@@ -45,6 +45,16 @@ jobs:
--health-interval=10s
--health-timeout=5s
--health-retries=5
valkey:
image: valkey/valkey:8.1.1
ports:
- 6379:6379
options: >-
--entrypoint "valkey-server"
--health-cmd="valkey-cli ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0

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

@@ -25,21 +25,9 @@ RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
# BuildKit secret handling without hardcoded fallback values
# This approach relies entirely on secrets passed from GitHub Actions
RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
# Copy the secrets handling script
COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
RUN chmod +x /tmp/read-secrets.sh
# Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096"
@@ -62,6 +50,9 @@ RUN touch apps/web/.env
# Install the dependencies
RUN pnpm install --ignore-scripts
# Build the database package first
RUN pnpm build --filter=@formbricks/database
# Build the project using our secret reader script
# This mounts the secrets only during this build step without storing them in layers
RUN --mount=type=secret,id=database_url \
@@ -106,20 +97,8 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/migration ./packages/database/migration
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
COPY --from=installer /app/packages/database/src ./packages/database/src
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/packages/database/dist ./packages/database/dist
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
@@ -142,12 +121,14 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
RUN npm install -g prisma
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
RUN chown nextjs:nextjs /home/nextjs/start.sh && chmod +x /home/nextjs/start.sh
EXPOSE 3000
ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV="production"
ENV HOSTNAME="0.0.0.0"
USER nextjs
# Prepare volume for uploads
@@ -158,12 +139,4 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js
CMD ["/home/nextjs/start.sh"]

View File

@@ -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",
@@ -86,6 +86,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next/navigation", () => ({

View File

@@ -1,15 +1,33 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { LandingSidebar } from "./landing-sidebar";
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
// Mock server actions that this test needs
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
// Module mocks must be declared before importing the component
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
}));
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
// Mock our useSignOut hook
const mockSignOut = vi.fn();
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: () => ({
signOut: mockSignOut,
}),
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open }: { open: boolean }) => (
@@ -70,6 +88,13 @@ describe("LandingSidebar component", () => {
const logoutItem = await screen.findByText("common.logout");
await userEvent.click(logoutItem);
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "o1",
redirect: true,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
});
});

View File

@@ -3,6 +3,7 @@
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import {
@@ -20,7 +21,6 @@ import {
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -44,6 +44,7 @@ export const LandingSidebar = ({
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
const { t } = useTranslate();
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const router = useRouter();
@@ -123,7 +124,14 @@ export const LandingSidebar = ({
<DropdownMenuItem
onClick={async () => {
await signOut({ callbackUrl: "/auth/login" });
await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: true,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

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",
@@ -89,6 +89,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/environment/service");

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",
@@ -98,6 +97,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({

View File

@@ -35,6 +35,8 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("next-auth", () => ({

View File

@@ -34,6 +34,8 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
// Mock dependencies

View File

@@ -26,6 +26,14 @@ vi.mock("@/lib/constants", () => ({
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
REDIS_URL: "redis://localhost:6379",
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("Contact Page Re-export", () => {

View File

@@ -4,7 +4,9 @@ import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getOrganizationProjectsLimit,
getRoleManagementPermission,
@@ -20,62 +22,69 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput,
});
export const createProjectAction = authenticatedActionClient
.schema(ZCreateProjectAction)
.action(async ({ parsedInput, ctx }) => {
const { user } = ctx;
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
withAuditLogging(
"created",
"project",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const { user } = ctx;
const organizationId = parsedInput.organizationId;
const organizationId = parsedInput.organizationId;
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkAuthorizationUpdated({
userId: user.id,
organizationId: parsedInput.organizationId,
access: [
{
data: parsedInput.data,
schema: ZProjectUpdateInput,
type: "organization",
roles: ["owner", "manager"],
},
],
});
const organization = await getOrganization(organizationId);
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization project limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
if (!organization) {
throw new Error("Organization not found");
}
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization project limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
return project;
}
const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = {
...user.notificationSettings,
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {
notificationSettings: updatedNotificationSettings,
});
return project;
});
)
);

View File

@@ -3,8 +3,10 @@
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { getSurveysByActionClassId } from "@/lib/survey/service";
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
@@ -14,63 +16,80 @@ const ZDeleteActionClassAction = z.object({
actionClassId: ZId,
});
export const deleteActionClassAction = authenticatedActionClient
.schema(ZDeleteActionClassAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
await deleteActionClass(parsedInput.actionClassId);
});
export const deleteActionClassAction = authenticatedActionClient.schema(ZDeleteActionClassAction).action(
withAuditLogging(
"deleted",
"actionClass",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId;
ctx.auditLoggingCtx.oldObject = await getActionClass(parsedInput.actionClassId);
return await deleteActionClass(parsedInput.actionClassId);
}
)
);
const ZUpdateActionClassAction = z.object({
actionClassId: ZId,
updatedAction: ZActionClassInput,
});
export const updateActionClassAction = authenticatedActionClient
.schema(ZUpdateActionClassAction)
.action(async ({ ctx, parsedInput }) => {
const actionClass = await getActionClass(parsedInput.actionClassId);
if (actionClass === null) {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
export const updateActionClassAction = authenticatedActionClient.schema(ZUpdateActionClassAction).action(
withAuditLogging(
"updated",
"actionClass",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const actionClass = await getActionClass(parsedInput.actionClassId);
if (actionClass === null) {
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
}
const organizationId = await getOrganizationIdFromActionClassId(parsedInput.actionClassId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.actionClassId = parsedInput.actionClassId;
ctx.auditLoggingCtx.oldObject = actionClass;
const result = await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
ctx.auditLoggingCtx.newObject = result;
return result;
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
},
],
});
return await updateActionClass(
actionClass.environmentId,
parsedInput.actionClassId,
parsedInput.updatedAction
);
});
)
);
const ZGetActiveInactiveSurveysAction = z.object({
actionClassId: ZId,

View File

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

View File

@@ -1,6 +1,6 @@
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
@@ -10,6 +10,17 @@ import { TUser } from "@formbricks/types/user";
import { getLatestStableFbReleaseAction } from "../actions/actions";
import { MainNavigation } from "./MainNavigation";
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
// Mock server actions that this test needs
vi.mock("@/modules/auth/actions/sign-out", () => ({
logSignOutAction: vi.fn().mockResolvedValue(undefined),
}));
// Mock dependencies
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
@@ -18,6 +29,9 @@ vi.mock("next/navigation", () => ({
vi.mock("next-auth/react", () => ({
signOut: vi.fn(),
}));
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
useSignOut: vi.fn(() => ({ signOut: vi.fn() })),
}));
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
getLatestStableFbReleaseAction: vi.fn(),
}));
@@ -203,7 +217,11 @@ describe("MainNavigation", () => {
});
test("renders user dropdown and handles logout", async () => {
vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" });
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
// Set up localStorage spy on the mocked localStorage
render(<MainNavigation {...defaultProps} />);
// Find the avatar and get its parent div which acts as the trigger
@@ -224,7 +242,15 @@ describe("MainNavigation", () => {
const logoutButton = screen.getByText("common.logout");
await userEvent.click(logoutButton);
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: "org1",
redirect: false,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
});

View File

@@ -6,6 +6,7 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
@@ -42,7 +43,6 @@ import {
UserIcon,
UsersIcon,
} from "lucide-react";
import { signOut } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
@@ -90,6 +90,7 @@ export const MainNavigation = ({
const [isCollapsed, setIsCollapsed] = useState(true);
const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const project = projects.find((project) => project.id === environment.projectId);
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
@@ -389,8 +390,15 @@ export const MainNavigation = ({
<DropdownMenuItem
onClick={async () => {
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
router.push(route.url);
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: false,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -2,13 +2,15 @@
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId,
getProjectIdFromEnvironmentId,
getProjectIdFromIntegrationId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZIntegrationInput } from "@formbricks/types/integration";
@@ -20,48 +22,79 @@ const ZCreateOrUpdateIntegrationAction = z.object({
export const createOrUpdateIntegrationAction = authenticatedActionClient
.schema(ZCreateOrUpdateIntegrationAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
.action(
withAuditLogging(
"createdUpdated",
"integration",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
return await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createOrUpdateIntegration(
parsedInput.environmentId,
parsedInput.integrationData
);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZDeleteIntegrationAction = z.object({
integrationId: ZId,
});
export const deleteIntegrationAction = authenticatedActionClient
.schema(ZDeleteIntegrationAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromIntegrationId(parsedInput.integrationId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
});
export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
withAuditLogging(
"deleted",
"integration",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const organizationId = await getOrganizationIdFromIntegrationId(parsedInput.integrationId);
return await deleteIntegration(parsedInput.integrationId);
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.integrationId = parsedInput.integrationId;
const result = await deleteIntegration(parsedInput.integrationId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
)
);

View File

@@ -49,6 +49,8 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/integration/service");

View File

@@ -2,7 +2,7 @@
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";

View File

@@ -32,6 +32,8 @@ vi.mock("@/lib/constants", () => ({
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000,
REDIS_URL: "mock-redis-url",
AUDIT_LOG_ENABLED: true,
}));
// Mock child components

View File

@@ -2,7 +2,7 @@
import { getSlackChannels } from "@/lib/slack/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";

View File

@@ -25,6 +25,14 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://example.com",
},
}));
describe("AppConnectionPage Re-export", () => {

View File

@@ -25,6 +25,14 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("GeneralSettingsPage re-export", () => {

View File

@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("LanguagesPage re-export", () => {

View File

@@ -25,6 +25,14 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("ProjectLookSettingsPage re-export", () => {

View File

@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
describe("TagsPage re-export", () => {

View File

@@ -25,6 +25,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
describe("ProjectTeams re-export", () => {

View File

@@ -41,6 +41,8 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);

View File

@@ -1,7 +1,9 @@
"use server";
import { updateUser } from "@/lib/user/service";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { z } from "zod";
import { ZUserNotificationSettings } from "@formbricks/types/user";
@@ -11,8 +13,25 @@ const ZUpdateNotificationSettingsAction = z.object({
export const updateNotificationSettingsAction = authenticatedActionClient
.schema(ZUpdateNotificationSettingsAction)
.action(async ({ ctx, parsedInput }) => {
await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
});
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, {
notificationSettings: parsedInput.notificationSettings,
});
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);

View File

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

View File

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

View File

@@ -7,11 +7,13 @@ import {
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { deleteFile } from "@/lib/storage/service";
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { updateUser } from "@/lib/user/service";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { sendVerificationNewEmail } from "@/modules/email";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
@@ -27,93 +29,154 @@ const limiter = rateLimit({
allowedPerInterval: 3, // max 3 calls for email verification per hour
});
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
};
}
async function handleEmailUpdate({
ctx,
parsedInput,
payload,
}: {
ctx: any;
parsedInput: any;
payload: TUserUpdateInput;
}) {
const inputEmail = parsedInput.email?.trim().toLowerCase();
if (!inputEmail || ctx.user.email === inputEmail) return payload;
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
if (!parsedInput.password) {
throw new AuthenticationError("Password is required to update email.");
}
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
if (!isCorrectPassword) {
throw new AuthorizationError("Incorrect credentials");
}
const isEmailUnique = await getIsEmailUnique(inputEmail);
if (!isEmailUnique) return payload;
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
}
return payload;
}
export const updateUserAction = authenticatedActionClient
.schema(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(),
})
)
.action(async ({ parsedInput, ctx }) => {
const inputEmail = parsedInput.email?.trim().toLowerCase();
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
let payload: TUserUpdateInput = {
...(parsedInput.name && { name: parsedInput.name }),
...(parsedInput.locale && { locale: parsedInput.locale }),
};
// Only process email update if a new email is provided and it's different from current email
if (inputEmail && ctx.user.email !== inputEmail) {
// Check rate limit
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
if (!parsedInput.password) {
throw new AuthenticationError("Password is required to update email.");
}
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
if (!isCorrectPassword) {
throw new AuthorizationError("Incorrect credentials");
}
// Check if the new email is unique, no user exists with the new email
const isEmailUnique = await getIsEmailUnique(inputEmail);
// If the new email is unique, proceed with the email update
if (isEmailUnique) {
if (EMAIL_VERIFICATION_DISABLED) {
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
}
}
// Only proceed with updateUser if we have actual changes to make
if (Object.keys(payload).length > 0) {
await updateUser(ctx.user.id, payload);
}
return true;
});
)
);
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),
});
export const updateAvatarAction = authenticatedActionClient
.schema(ZUpdateAvatarAction)
.action(async ({ parsedInput, ctx }) => {
return await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
});
export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZRemoveAvatarAction = z.object({
environmentId: ZId,
});
export const removeAvatarAction = authenticatedActionClient
.schema(ZRemoveAvatarAction)
.action(async ({ parsedInput, ctx }) => {
const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) {
throw new Error("Invalid filename");
}
const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) {
throw new Error("Invalid filename");
}
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) {
throw new Error("Deletion failed");
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) {
throw new Error("Deletion failed");
}
const result = await updateUser(ctx.user.id, { imageUrl: null });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
return await updateUser(ctx.user.id, { imageUrl: null });
});
)
);
export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging(
"passwordReset",
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
}
await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id;
return { success: true };
}
)
);

View File

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

View File

@@ -3,6 +3,7 @@
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -13,17 +14,16 @@ import {
} from "@/modules/ui/components/dropdown-menu";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon } from "lucide-react";
import { signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { updateUserAction } from "../actions";
import { resetPasswordAction, updateUserAction } from "../actions";
// Schema & types
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({
@@ -31,15 +31,18 @@ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email:
});
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
interface IEditProfileDetailsFormProps {
user: TUser;
isPasswordResetEnabled?: boolean;
emailVerificationDisabled: boolean;
}
export const EditProfileDetailsForm = ({
user,
isPasswordResetEnabled,
emailVerificationDisabled,
}: {
user: TUser;
emailVerificationDisabled: boolean;
}) => {
}: IEditProfileDetailsFormProps) => {
const { t } = useTranslate();
const router = useRouter();
const form = useForm<TEditProfileNameForm>({
defaultValues: {
@@ -52,7 +55,10 @@ export const EditProfileDetailsForm = ({
});
const { isSubmitting, isDirty } = form.formState;
const [isResettingPassword, setIsResettingPassword] = useState(false);
const [showModal, setShowModal] = useState(false);
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const handleConfirmPassword = async (password: string) => {
const values = form.getValues();
@@ -86,8 +92,13 @@ export const EditProfileDetailsForm = ({
toast.success(t("auth.verification-requested.new_email_verification_success"));
} else {
toast.success(t("environments.settings.profile.email_change_initiated"));
await signOut({ redirect: false });
router.push(`/email-change-without-verification-success`);
await signOutWithAudit({
reason: "email_change",
redirectUrl: "/email-change-without-verification-success",
redirect: true,
callbackUrl: "/email-change-without-verification-success",
clearEnvironmentId: true,
});
return;
}
} else {
@@ -118,6 +129,28 @@ export const EditProfileDetailsForm = ({
}
};
const handleResetPassword = async () => {
setIsResettingPassword(true);
const result = await resetPasswordAction();
if (result?.data) {
toast.success(t("auth.forgot-password.email-sent.heading"));
await signOutWithAudit({
reason: "password_reset",
redirectUrl: "/auth/login",
redirect: true,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(t(errorMessage));
}
setIsResettingPassword(false);
};
return (
<>
<FormProvider {...form}>
@@ -202,6 +235,26 @@ export const EditProfileDetailsForm = ({
)}
/>
{isPasswordResetEnabled && (
<div className="mt-4 space-y-2">
<Label htmlFor="reset-password">{t("auth.forgot-password.reset_password")}</Label>
<p className="mt-1 text-sm text-slate-500">
{t("auth.forgot-password.reset_password_description")}
</p>
<div className="flex items-center justify-between gap-2">
<Input type="email" id="reset-password" defaultValue={user.email} disabled />
<Button
onClick={handleResetPassword}
loading={isResettingPassword}
disabled={isResettingPassword}
size="default"
variant="secondary">
{t("auth.forgot-password.reset_password")}
</Button>
</div>
</div>
)}
<Button
type="submit"
className="mt-4"

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
"use server";
import { deleteOrganization, updateOrganization } from "@/lib/organization/service";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
@@ -16,43 +18,65 @@ const ZUpdateOrganizationNameAction = z.object({
export const updateOrganizationNameAction = authenticatedActionClient
.schema(ZUpdateOrganizationNameAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
return await updateOrganization(parsedInput.organizationId, parsedInput.data);
});
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZDeleteOrganizationAction = z.object({
organizationId: ZId,
});
export const deleteOrganizationAction = authenticatedActionClient
.schema(ZDeleteOrganizationAction)
.action(async ({ parsedInput, ctx }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
withAuditLogging(
"deleted",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
return await deleteOrganization(parsedInput.organizationId);
});
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
);

View File

@@ -30,6 +30,14 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
describe("TeamsPage re-export", () => {

View File

@@ -2,7 +2,7 @@
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { revalidatePath } from "next/cache";
import { z } from "zod";

View File

@@ -45,6 +45,14 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/env", () => ({
env: {
PUBLIC_URL: "https://public-domain.com",
},
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");

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

@@ -3,8 +3,10 @@
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
@@ -63,37 +65,55 @@ const ZGenerateResultShareUrlAction = z.object({
export const generateResultShareUrlAction = authenticatedActionClient
.schema(ZGenerateResultShareUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
.action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20
)();
const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20
)();
await updateSurvey({ ...survey, resultShareKey });
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
return resultShareKey;
});
const newSurvey = await updateSurvey({ ...survey, resultShareKey });
ctx.auditLoggingCtx.newObject = newSurvey;
return resultShareKey;
}
)
);
const ZGetResultShareUrlAction = z.object({
surveyId: ZId,
@@ -132,30 +152,50 @@ const ZDeleteResultShareUrlAction = z.object({
export const deleteResultShareUrlAction = authenticatedActionClient
.schema(ZDeleteResultShareUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
.action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
return await updateSurvey({ ...survey, resultShareKey: null });
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
const newSurvey = await updateSurvey({ ...survey, resultShareKey: null });
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
}
)
);
const ZGetEmailHtmlAction = z.object({
surveyId: ZId,

View File

@@ -41,6 +41,36 @@ const mockSurveyWeb = {
styling: null,
} as unknown as TSurvey;
vi.mock("@/lib/constants", () => ({
INTERCOM_SECRET_KEY: "test-secret-key",
IS_INTERCOM_CONFIGURED: true,
INTERCOM_APP_ID: "test-app-id",
ENCRYPTION_KEY: "test-encryption-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
GITHUB_ID: "test-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
IS_FORMBRICKS_CLOUD: false,
}));
const mockSurveyLink = {
...mockSurveyWeb,
id: "survey2",
@@ -119,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,
@@ -128,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>
@@ -136,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>
)
@@ -146,8 +176,8 @@ describe("ShareEmbedSurvey", () => {
));
});
test("renders initial 'start' view correctly when open and modalView is 'start'", () => {
render(<ShareEmbedSurvey {...defaultProps} />);
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} />);
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
@@ -158,6 +188,18 @@ describe("ShareEmbedSurvey", () => {
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("renders initial 'start' view correctly when open and modalView is 'start' for app survey", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} />);
// For app surveys, ShareSurveyLink should not be rendered
expect(screen.queryByText("ShareSurveyLinkMock")).not.toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />);
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
@@ -174,20 +216,32 @@ describe("ShareEmbedSurvey", () => {
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
});
test("calls setOpen(false) when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="embed" />);
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
const embedViewButton = screen.getByText("EmbedViewMockContent");
await userEvent.click(embedViewButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("calls setOpen(false) when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} modalView="panel" />);
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
await userEvent.click(panelInfoViewButton);
expect(mockSetOpen).toHaveBeenCalledWith(false);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
@@ -215,8 +269,8 @@ describe("ShareEmbedSurvey", () => {
};
expect(embedViewProps.tabs.length).toBe(3);
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
expect(embedViewProps.tabs[0].id).toBe("email");
expect(embedViewProps.activeId).toBe("email");
expect(embedViewProps.tabs[0].id).toBe("link");
expect(embedViewProps.activeId).toBe("link");
});
test("correctly configures for 'web' survey type in embed view", () => {

View File

@@ -1,6 +1,7 @@
"use client";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Badge } from "@/modules/ui/components/badge";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
@@ -23,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>>;
@@ -32,7 +33,7 @@ interface ShareEmbedSurveyProps {
export const ShareEmbedSurvey = ({
survey,
surveyDomain,
publicDomain,
open,
modalView,
setOpen,
@@ -46,13 +47,14 @@ export const ShareEmbedSurvey = ({
const tabs = useMemo(
() =>
[
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
{
id: "link",
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
icon: LinkIcon,
},
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
{ id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon },
].filter((tab) => !(survey.type === "link" && tab.id === "app")),
[t, isSingleUseLinkSurvey, survey.type]
@@ -62,6 +64,20 @@ export const ShareEmbedSurvey = ({
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
const [surveyUrl, setSurveyUrl] = useState("");
useEffect(() => {
const fetchSurveyUrl = async () => {
try {
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(`${publicDomain}/s/${survey.id}`);
}
};
fetchSurveyUrl();
}, [survey, publicDomain]);
useEffect(() => {
if (survey.type !== "link") {
setActiveId(tabs[3].id);
@@ -86,32 +102,33 @@ export const ShareEmbedSurvey = ({
};
const handleInitialPageButton = () => {
setOpen(false);
setShowView("start");
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTitle className="sr-only" />
<DialogContent className="w-full max-w-xl bg-white p-0 md:max-w-3xl lg:h-[700px] lg:max-w-5xl">
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
{showView === "start" ? (
<div className="h-full max-w-full overflow-hidden">
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">
<DialogTitle>
<p className="pt-2 text-xl font-semibold text-slate-800">
{t("environments.surveys.summary.your_survey_is_public")} 🎉
</p>
</DialogTitle>
<DialogDescription className="hidden" />
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
surveyDomain={surveyDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
</div>
<div className="flex h-[300px] flex-col items-center justify-center gap-8 rounded-b-lg bg-slate-50 px-8 lg:h-3/5">
<p className="-mt-8 text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
<div className="flex h-full max-w-full flex-col overflow-hidden">
{survey.type === "link" && (
<div className="flex h-2/5 w-full flex-col items-center justify-center space-y-6 p-8 text-center">
<DialogTitle>
<p className="pt-2 text-xl font-semibold text-slate-800">
{t("environments.surveys.summary.your_survey_is_public")} 🎉
</p>
</DialogTitle>
<DialogDescription className="hidden" />
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
</div>
)}
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-b-lg bg-slate-50 px-8">
<p className="text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
<div className="grid grid-cols-4 gap-2">
<button
type="button"
@@ -159,7 +176,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

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

View File

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

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

View File

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

View File

@@ -5,8 +5,10 @@ import { getResponseDownloadUrl, getResponseFilteringValues } from "@/lib/respon
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
@@ -14,7 +16,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
const ZGetResponsesDownloadUrlAction = z.object({
surveyId: ZId,
@@ -102,39 +104,54 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
}
};
export const updateSurveyAction = authenticatedActionClient
.schema(ZSurvey)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging(
"updated",
"survey",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const { followUps } = parsedInput;
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
if (parsedInput.languages?.length) {
await checkMultiLanguagePermission(organizationId);
}
return await updateSurvey(parsedInput);
});
)
);

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

@@ -39,6 +39,8 @@ vi.mock("@/lib/constants", () => ({
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({

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

@@ -7,6 +7,8 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
@@ -179,10 +181,33 @@ export const POST = async (request: Request) => {
// Update survey status if necessary
if (survey.autoComplete && responseCount >= survey.autoComplete) {
await updateSurvey({
...survey,
status: "completed",
});
let logStatus: TAuditStatus = "success";
try {
await updateSurvey({
...survey,
status: "completed",
});
} catch (error) {
logStatus = "failure";
logger.error(
{ error, url: request.url, surveyId },
`Failed to update survey ${surveyId} status to completed`
);
} finally {
await queueAuditEvent({
status: logStatus,
action: "updated",
targetType: "survey",
userId: UNKNOWN_DATA,
userType: "system",
targetId: survey.id,
organizationId: organization.id,
newObject: {
status: "completed",
},
});
}
}
// Await webhook and email promises with allSettled to prevent early rejection

View File

@@ -1,8 +1,140 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
import NextAuth from "next-auth";
import { logger } from "@formbricks/logger";
export const fetchCache = "force-no-store";
const handler = NextAuth(authOptions);
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
const authOptions = {
...baseAuthOptions,
callbacks: {
...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) {
let result: any = params.session;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.session) {
result = await baseAuthOptions.callbacks.session(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("Session callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
if (error) throw error;
return result;
},
async signIn({ user, account, profile, email, credentials }) {
let result: boolean | string = true;
let error: any = undefined;
let authMethod = "unknown";
try {
if (baseAuthOptions.callbacks?.signIn) {
result = await baseAuthOptions.callbacks.signIn({
user,
account,
profile,
email,
credentials,
});
}
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) {
error = err;
result = false;
logger.withContext({ eventId, err }).error("User sign-in failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
const status: TAuditStatus = result === false ? "failure" : "success";
const auditLog = {
action: "signedIn" as const,
targetType: "user" as const,
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error ? { errorMessage: error.message } : {}),
},
...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (error) throw error;
return result;
},
},
};
return NextAuth(authOptions)(req, ctx);
};
export { handler as GET, handler as POST };

View File

@@ -1,3 +0,0 @@
import { GET } from "@/modules/ee/auth/saml/api/well-known/cert/route";
export { GET };

View File

@@ -1,3 +0,0 @@
import { GET } from "@/modules/ee/auth/saml/api/well-known/sp-metadata/route";
export { GET };

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

@@ -1,6 +1,7 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger";
@@ -44,63 +45,104 @@ export const GET = async (
}
};
export const PUT = async (
request: Request,
props: { params: Promise<{ actionClassId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId);
}
let actionClassUpdate;
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
try {
actionClassUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const inputValidation = ZActionClassInput.safeParse(actionClassUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
if (!actionClass) {
return {
response: responses.notFoundResponse("Action Class", params.actionClassId),
};
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
let actionClassUpdate;
try {
actionClassUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZActionClassInput.safeParse(actionClassUpdate);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
),
};
}
const updatedActionClass = await updateActionClass(
inputValidation.data.environmentId,
params.actionClassId,
inputValidation.data
);
if (updatedActionClass) {
auditLog.newObject = updatedActionClass;
return {
response: responses.successResponse(updatedActionClass),
};
}
return {
response: responses.internalServerErrorResponse("Some error occurred while updating action"),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
const updatedActionClass = await updateActionClass(
inputValidation.data.environmentId,
params.actionClassId,
inputValidation.data
);
if (updatedActionClass) {
return responses.successResponse(updatedActionClass);
}
return responses.internalServerErrorResponse("Some error ocured while updating action");
} catch (error) {
return handleErrorResponse(error);
}
};
},
"updated",
"actionClass"
);
export const DELETE = async (
request: Request,
props: { params: Promise<{ actionClassId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId);
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ actionClassId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.actionClassId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
if (!actionClass) {
return {
response: responses.notFoundResponse("Action Class", params.actionClassId),
};
}
auditLog.oldObject = actionClass;
auditLog.organizationId = authentication.organizationId;
const deletedActionClass = await deleteActionClass(params.actionClassId);
return {
response: responses.successResponse(deletedActionClass),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
const deletedActionClass = await deleteActionClass(params.actionClassId);
return responses.successResponse(deletedActionClass);
} catch (error) {
return handleErrorResponse(error);
}
};
},
"deleted",
"actionClass"
);

View File

@@ -1,6 +1,7 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { createActionClass } from "@/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { logger } from "@formbricks/logger";
@@ -28,41 +29,62 @@ export const GET = async (request: Request) => {
}
};
export const POST = async (request: Request): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
let actionClassInput;
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
try {
actionClassInput = await request.json();
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
let actionClassInput;
try {
actionClassInput = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
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;
return {
response: responses.successResponse(actionClass),
};
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
}
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
return responses.successResponse(actionClass);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
},
"created",
"actionClass"
);

View File

@@ -1,6 +1,7 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -48,58 +49,101 @@ export const GET = async (
}
};
export const DELETE = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) return result.error;
const deletedResponse = await deleteResponse(params.responseId);
return responses.successResponse(deletedResponse);
} catch (error) {
return handleErrorResponse(error);
}
};
export const PUT = async (
request: Request,
props: { params: Promise<{ responseId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) return result.error;
let responseUpdate;
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.responseId;
try {
responseUpdate = await request.json();
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.response;
const deletedResponse = await deleteResponse(params.responseId);
return {
response: responses.successResponse(deletedResponse),
};
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
return {
response: handleErrorResponse(error),
};
}
},
"deleted",
"response"
);
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ responseId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.responseId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
);
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.response;
let responseUpdate;
try {
responseUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
}
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
),
};
}
const updated = await updateResponse(params.responseId, inputValidation.data);
auditLog.newObject = updated;
return {
response: responses.successResponse(updated),
};
} catch (error) {
return {
response: handleErrorResponse(error),
};
}
return responses.successResponse(await updateResponse(params.responseId, inputValidation.data));
} catch (error) {
return handleErrorResponse(error);
}
};
},
"updated",
"response"
);

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { validateFileUploads } from "@/lib/fileValidation";
import { getResponses } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -91,46 +92,82 @@ const validateSurvey = async (responseInput: TResponseInput, environmentId: stri
return { survey };
};
export const POST = async (request: Request): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const inputResult = await validateInput(request);
if (inputResult.error) return inputResult.error;
const responseInput = inputResult.data;
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
const surveyResult = await validateSurvey(responseInput, environmentId);
if (surveyResult.error) return surveyResult.error;
if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) {
return responses.badRequestResponse("Invalid file upload response");
}
if (responseInput.createdAt && !responseInput.updatedAt) {
responseInput.updatedAt = responseInput.createdAt;
}
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
try {
const response = await createResponse(responseInput);
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return responses.internalServerErrorResponse(error.message);
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const inputResult = await validateInput(request);
if (inputResult.error) {
return {
response: inputResult.error,
};
}
const responseInput = inputResult.data;
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
const surveyResult = await validateSurvey(responseInput, environmentId);
if (surveyResult.error) {
return {
response: surveyResult.error,
};
}
if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
};
}
if (responseInput.createdAt && !responseInput.updatedAt) {
responseInput.updatedAt = responseInput.createdAt;
}
try {
const response = await createResponse(responseInput);
auditLog.targetId = response.id;
auditLog.newObject = response;
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
} else if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
return {
response: responses.internalServerErrorResponse(error.message),
};
}
} catch (error) {
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse("An unexpected error occurred while creating the response"),
};
}
throw error;
}
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
},
"created",
"response"
);

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

@@ -3,6 +3,7 @@ import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/sur
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -42,64 +43,121 @@ export const GET = async (
}
};
export const DELETE = async (
request: Request,
props: { params: Promise<{ surveyId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (result.error) return result.error;
const deletedSurvey = await deleteSurvey(params.surveyId);
return responses.successResponse(deletedSurvey);
} catch (error) {
return handleErrorResponse(error);
}
};
export const PUT = async (
request: Request,
props: { params: Promise<{ surveyId: string }> }
): Promise<Response> => {
const params = await props.params;
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) return result.error;
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
let surveyUpdate;
export const DELETE = withApiLogging(
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.surveyId;
try {
surveyUpdate = await request.json();
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
auditLog.organizationId = authentication.organizationId;
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.survey;
const deletedSurvey = await deleteSurvey(params.surveyId);
return {
response: responses.successResponse(deletedSurvey),
};
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
return {
response: handleErrorResponse(error),
};
}
},
"deleted",
"survey"
);
const inputValidation = ZSurveyUpdateInput.safeParse({
...result.survey,
...surveyUpdate,
});
export const PUT = withApiLogging(
async (request: Request, props: { params: Promise<{ surveyId: string }> }, auditLog: ApiAuditLog) => {
const params = await props.params;
auditLog.targetId = params.surveyId;
try {
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
);
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) {
return {
response: result.error,
};
}
auditLog.oldObject = result.survey;
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
if (!organization) {
return {
response: responses.notFoundResponse("Organization", null),
};
}
auditLog.organizationId = organization.id;
let surveyUpdate;
try {
surveyUpdate = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZSurveyUpdateInput.safeParse({
...result.survey,
...surveyUpdate,
});
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error)
),
};
}
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization);
if (featureCheckResult) {
return {
response: featureCheckResult,
};
}
try {
const updatedSurvey = await updateSurvey({ ...inputValidation.data, id: params.surveyId });
auditLog.newObject = updatedSurvey;
return {
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
auditLog.status = "failure";
return {
response: handleErrorResponse(error),
};
}
} catch (error) {
auditLog.status = "failure";
return {
response: handleErrorResponse(error),
};
}
const featureCheckResult = await checkFeaturePermissions(surveyUpdate, organization);
if (featureCheckResult) return featureCheckResult;
return responses.successResponse(await updateSurvey({ ...inputValidation.data, id: params.surveyId }));
} catch (error) {
return handleErrorResponse(error);
}
};
},
"updated",
"survey"
);

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

@@ -2,6 +2,7 @@ import { authenticateRequest } from "@/app/api/v1/auth";
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { ApiAuditLog, withApiLogging } from "@/app/lib/api/with-api-logging";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
@@ -33,50 +34,78 @@ export const GET = async (request: Request) => {
}
};
export const POST = async (request: Request): Promise<Response> => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
let surveyInput;
export const POST = withApiLogging(
async (request: Request, _, auditLog: ApiAuditLog) => {
try {
surveyInput = await request.json();
const authentication = await authenticateRequest(request);
if (!authentication) {
return {
response: responses.notAuthenticatedResponse(),
};
}
auditLog.userId = authentication.apiKeyId;
let surveyInput;
try {
surveyInput = await request.json();
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
const { environmentId } = inputValidation.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
return {
response: responses.notFoundResponse("Organization", null),
};
}
auditLog.organizationId = organization.id;
const surveyData = { ...inputValidation.data, environmentId };
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
if (featureCheckResult) {
return {
response: featureCheckResult,
};
}
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
auditLog.targetId = survey.id;
auditLog.newObject = survey;
return {
response: responses.successResponse(survey),
};
} catch (error) {
logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
};
}
throw error;
}
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
const { environmentId } = inputValidation.data;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
const surveyData = { ...inputValidation.data, environmentId };
const featureCheckResult = await checkFeaturePermissions(surveyData, organization);
if (featureCheckResult) return featureCheckResult;
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
return responses.successResponse(survey);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
}
throw error;
}
};
},
"created",
"survey"
);

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