mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
Compare commits
11 Commits
testing/mi
...
6095-pr-ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb97244a1b | ||
|
|
b02d36ec5a | ||
|
|
423f149208 | ||
|
|
cb4efa2334 | ||
|
|
76373267dc | ||
|
|
14bc7c4717 | ||
|
|
136816d769 | ||
|
|
ebdfac8307 | ||
|
|
0bef6a4cdf | ||
|
|
313fd3f214 | ||
|
|
e883e22d26 |
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description: Migrate deprecated UI components to a unified component
|
description:
|
||||||
globs:
|
globs:
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
@@ -7,7 +7,6 @@ description: >
|
|||||||
globs: []
|
globs: []
|
||||||
alwaysApply: agent-requested
|
alwaysApply: agent-requested
|
||||||
---
|
---
|
||||||
|
|
||||||
# Formbricks Database Schema Reference
|
# 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.
|
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.
|
||||||
@@ -17,7 +16,6 @@ This rule provides a reference to the Formbricks database structure. For the mos
|
|||||||
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
||||||
|
|
||||||
### Core Hierarchy
|
### Core Hierarchy
|
||||||
|
|
||||||
```
|
```
|
||||||
Organization
|
Organization
|
||||||
└── Project
|
└── Project
|
||||||
@@ -31,7 +29,6 @@ Organization
|
|||||||
## Schema Reference
|
## Schema Reference
|
||||||
|
|
||||||
For the complete and up-to-date database schema, please refer to:
|
For the complete and up-to-date database schema, please refer to:
|
||||||
|
|
||||||
- Main schema: `packages/database/schema.prisma`
|
- Main schema: `packages/database/schema.prisma`
|
||||||
- JSON type definitions: `packages/database/json-types.ts`
|
- JSON type definitions: `packages/database/json-types.ts`
|
||||||
|
|
||||||
@@ -40,22 +37,17 @@ The schema.prisma file contains all model definitions, relationships, enums, and
|
|||||||
## Data Access Patterns
|
## Data Access Patterns
|
||||||
|
|
||||||
### Multi-tenancy
|
### Multi-tenancy
|
||||||
|
|
||||||
- All data is scoped by Organization
|
- All data is scoped by Organization
|
||||||
- Environment-level isolation for surveys and contacts
|
- Environment-level isolation for surveys and contacts
|
||||||
- Project-level grouping for related surveys
|
- Project-level grouping for related surveys
|
||||||
|
|
||||||
### Soft Deletion
|
### Soft Deletion
|
||||||
|
|
||||||
Some models use soft deletion patterns:
|
Some models use soft deletion patterns:
|
||||||
|
|
||||||
- Check `isActive` fields where present
|
- Check `isActive` fields where present
|
||||||
- Use proper filtering in queries
|
- Use proper filtering in queries
|
||||||
|
|
||||||
### Cascading Deletes
|
### Cascading Deletes
|
||||||
|
|
||||||
Configured cascade relationships:
|
Configured cascade relationships:
|
||||||
|
|
||||||
- Organization deletion cascades to all child entities
|
- Organization deletion cascades to all child entities
|
||||||
- Survey deletion removes responses, displays, triggers
|
- Survey deletion removes responses, displays, triggers
|
||||||
- Contact deletion removes attributes and responses
|
- Contact deletion removes attributes and responses
|
||||||
@@ -63,7 +55,6 @@ Configured cascade relationships:
|
|||||||
## Common Query Patterns
|
## Common Query Patterns
|
||||||
|
|
||||||
### Survey with Responses
|
### Survey with Responses
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Include response count and latest responses
|
// Include response count and latest responses
|
||||||
const survey = await prisma.survey.findUnique({
|
const survey = await prisma.survey.findUnique({
|
||||||
@@ -71,40 +62,40 @@ const survey = await prisma.survey.findUnique({
|
|||||||
include: {
|
include: {
|
||||||
responses: {
|
responses: {
|
||||||
take: 10,
|
take: 10,
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: 'desc' }
|
||||||
},
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: { responses: true },
|
select: { responses: true }
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Scoping
|
### Environment Scoping
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Always scope by environment
|
// Always scope by environment
|
||||||
const surveys = await prisma.survey.findMany({
|
const surveys = await prisma.survey.findMany({
|
||||||
where: {
|
where: {
|
||||||
environmentId: environmentId,
|
environmentId: environmentId,
|
||||||
// Additional filters...
|
// Additional filters...
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Contact with Attributes
|
### Contact with Attributes
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const contact = await prisma.contact.findUnique({
|
const contact = await prisma.contact.findUnique({
|
||||||
where: { id: contactId },
|
where: { id: contactId },
|
||||||
include: {
|
include: {
|
||||||
attributes: {
|
attributes: {
|
||||||
include: {
|
include: {
|
||||||
attributeKey: true,
|
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.
|
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ apps/web/
|
|||||||
│ ├── (app)/ # Main application routes
|
│ ├── (app)/ # Main application routes
|
||||||
│ ├── (auth)/ # Authentication routes
|
│ ├── (auth)/ # Authentication routes
|
||||||
│ ├── api/ # API routes
|
│ ├── api/ # API routes
|
||||||
|
│ └── share/ # Public sharing routes
|
||||||
├── components/ # Shared components
|
├── components/ # Shared components
|
||||||
├── lib/ # Utility functions and services
|
├── lib/ # Utility functions and services
|
||||||
└── modules/ # Feature-specific modules
|
└── modules/ # Feature-specific modules
|
||||||
@@ -42,6 +43,7 @@ The application uses Next.js 13+ app router with route groups:
|
|||||||
### Dynamic Routes
|
### Dynamic Routes
|
||||||
- `[environmentId]` - Environment-specific routes
|
- `[environmentId]` - Environment-specific routes
|
||||||
- `[surveyId]` - Survey-specific routes
|
- `[surveyId]` - Survey-specific routes
|
||||||
|
- `[sharingKey]` - Public sharing routes
|
||||||
|
|
||||||
## Service Layer Pattern
|
## Service Layer Pattern
|
||||||
|
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
---
|
|
||||||
description: Security best practices and guidelines for writing GitHub Actions and workflows
|
|
||||||
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
|
|
||||||
---
|
|
||||||
|
|
||||||
# GitHub Actions Security Best Practices
|
|
||||||
|
|
||||||
## Required Security Measures
|
|
||||||
|
|
||||||
### 1. Set Minimum GITHUB_TOKEN Permissions
|
|
||||||
|
|
||||||
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
# Only add additional permissions if absolutely necessary:
|
|
||||||
# pull-requests: write # for commenting on PRs
|
|
||||||
# issues: write # for creating/updating issues
|
|
||||||
# checks: write # for publishing check results
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add Harden-Runner as First Step
|
|
||||||
|
|
||||||
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Harden the runner
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit # or 'block' for stricter security
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Pin Actions to Full Commit SHA
|
|
||||||
|
|
||||||
**Always** pin third-party actions to their full commit SHA, not tags:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - uses mutable tag
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# ✅ GOOD - pinned to immutable commit SHA
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Secure Variable Handling
|
|
||||||
|
|
||||||
Prevent command injection by properly quoting variables:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - potential command injection
|
|
||||||
run: echo "Processing ${{ inputs.user_input }}"
|
|
||||||
|
|
||||||
# ✅ GOOD - properly quoted
|
|
||||||
env:
|
|
||||||
USER_INPUT: ${{ inputs.user_input }}
|
|
||||||
run: echo "Processing ${USER_INPUT}"
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
|
|
||||||
|
|
||||||
### 5. Environment Variables for Secrets
|
|
||||||
|
|
||||||
Store sensitive data in environment variables, not inline:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD
|
|
||||||
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
|
|
||||||
|
|
||||||
# ✅ GOOD
|
|
||||||
env:
|
|
||||||
API_TOKEN: ${{ secrets.TOKEN }}
|
|
||||||
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Structure Best Practices
|
|
||||||
|
|
||||||
### Required Workflow Elements
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: "Descriptive Workflow Name"
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Define specific triggers
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
# Always set explicit permissions
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
job-name:
|
|
||||||
name: "Descriptive Job Name"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30 # tune per job; standardize repo-wide
|
|
||||||
|
|
||||||
# Set job-level permissions if different from workflow level
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# Always start with Harden-Runner on ubuntu-latest
|
|
||||||
- name: Harden the runner
|
|
||||||
uses: step-security/harden-runner@v2
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
# Pin all actions to commit SHA
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Input Validation for Actions
|
|
||||||
|
|
||||||
For composite actions, always validate inputs:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
inputs:
|
|
||||||
user_input:
|
|
||||||
description: "User provided input"
|
|
||||||
required: true
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: "composite"
|
|
||||||
steps:
|
|
||||||
- name: Validate input
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Harden shell and validate input format/content before use
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
USER_INPUT="${{ inputs.user_input }}"
|
|
||||||
|
|
||||||
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
|
||||||
echo "❌ Invalid input format"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Security in Actions
|
|
||||||
|
|
||||||
### Pin Docker Images to Digests
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - mutable tag
|
|
||||||
container: node:18
|
|
||||||
|
|
||||||
# ✅ GOOD - pinned to digest
|
|
||||||
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Secure File Operations
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Process files securely
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
FILE_PATH: ${{ inputs.file_path }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail # Fail on errors, undefined vars, pipe failures
|
|
||||||
|
|
||||||
# Use absolute paths and validate
|
|
||||||
SAFE_PATH=$(realpath "${FILE_PATH}")
|
|
||||||
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
|
|
||||||
echo "❌ Path outside workspace"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Artifact Handling
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Upload artifacts securely
|
|
||||||
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
|
|
||||||
with:
|
|
||||||
name: build-artifacts
|
|
||||||
path: |
|
|
||||||
dist/
|
|
||||||
!dist/**/*.log # Exclude sensitive files
|
|
||||||
retention-days: 30
|
|
||||||
```
|
|
||||||
|
|
||||||
### GHCR authentication for pulls/scans
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Minimal permissions required for GHCR pulls/scans
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Checklist
|
|
||||||
|
|
||||||
- [ ] Minimum GITHUB_TOKEN permissions set
|
|
||||||
- [ ] Harden-Runner added to all ubuntu-latest jobs
|
|
||||||
- [ ] All third-party actions pinned to commit SHA
|
|
||||||
- [ ] Input validation implemented for custom actions
|
|
||||||
- [ ] Variables properly quoted in shell scripts
|
|
||||||
- [ ] Secrets stored in environment variables
|
|
||||||
- [ ] Docker images pinned to digests (if used)
|
|
||||||
- [ ] Error handling with `set -euo pipefail`
|
|
||||||
- [ ] File paths validated and sanitized
|
|
||||||
- [ ] No sensitive data in logs or outputs
|
|
||||||
- [ ] GHCR login performed before pulls/scans (packages: read)
|
|
||||||
- [ ] Job timeouts configured (`timeout-minutes`)
|
|
||||||
|
|
||||||
## Recommended Additional Workflows
|
|
||||||
|
|
||||||
Consider adding these security-focused workflows to your repository:
|
|
||||||
|
|
||||||
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
|
|
||||||
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
|
|
||||||
3. **Dependabot Configuration** - Automated dependency updates
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
|
|
||||||
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
|
|
||||||
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
---
|
|
||||||
description: Create a story in Storybook for a given component
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Formbricks Storybook Stories
|
|
||||||
|
|
||||||
## When generating Storybook stories for Formbricks components:
|
|
||||||
|
|
||||||
### 1. **File Structure**
|
|
||||||
- Create `stories.tsx` (not `.stories.tsx`) in component directory
|
|
||||||
- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";`
|
|
||||||
- Import component from `"./index"`
|
|
||||||
|
|
||||||
### 2. **Story Structure Template**
|
|
||||||
```tsx
|
|
||||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
|
||||||
import { ComponentName } from "./index";
|
|
||||||
|
|
||||||
// For complex components with configurable options
|
|
||||||
// consider this as an example the options need to reflect the props types
|
|
||||||
interface StoryOptions {
|
|
||||||
showIcon: boolean;
|
|
||||||
numberOfElements: number;
|
|
||||||
customLabels: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type StoryProps = React.ComponentProps<typeof ComponentName> & StoryOptions;
|
|
||||||
|
|
||||||
const meta: Meta<StoryProps> = {
|
|
||||||
title: "UI/ComponentName",
|
|
||||||
component: ComponentName,
|
|
||||||
tags: ["autodocs"],
|
|
||||||
parameters: {
|
|
||||||
layout: "centered",
|
|
||||||
controls: { sort: "alpha", exclude: [] },
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component: "The **ComponentName** component provides [description].",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
// Organize in exactly these categories: Behavior, Appearance, Content
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ComponentName> & { args: StoryOptions };
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **ArgTypes Organization**
|
|
||||||
Organize ALL argTypes into exactly three categories:
|
|
||||||
- **Behavior**: disabled, variant, onChange, etc.
|
|
||||||
- **Appearance**: size, color, layout, styling, etc.
|
|
||||||
- **Content**: text, icons, numberOfElements, etc.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
```tsx
|
|
||||||
argTypes: {
|
|
||||||
propName: {
|
|
||||||
control: "select" | "boolean" | "text" | "number",
|
|
||||||
options: ["option1", "option2"], // for select
|
|
||||||
description: "Clear description",
|
|
||||||
table: {
|
|
||||||
category: "Behavior" | "Appearance" | "Content",
|
|
||||||
type: { summary: "string" },
|
|
||||||
defaultValue: { summary: "default" },
|
|
||||||
},
|
|
||||||
order: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Required Stories**
|
|
||||||
Every component must include:
|
|
||||||
- `Default`: Most common use case
|
|
||||||
- `Disabled`: If component supports disabled state
|
|
||||||
- `WithIcon`: If component supports icons
|
|
||||||
- Variant stories for each variant (Primary, Secondary, Error, etc.)
|
|
||||||
- Edge case stories (ManyElements, LongText, CustomStyling)
|
|
||||||
|
|
||||||
### 5. **Story Format**
|
|
||||||
```tsx
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
// Props with realistic values
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EdgeCase: Story = {
|
|
||||||
args: { /* ... */ },
|
|
||||||
parameters: {
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
story: "Use this when [specific scenario].",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. **Dynamic Content Pattern**
|
|
||||||
For components with dynamic content, create render function:
|
|
||||||
```tsx
|
|
||||||
const renderComponent = (args: StoryProps) => {
|
|
||||||
const { numberOfElements, showIcon, customLabels } = args;
|
|
||||||
|
|
||||||
// Generate dynamic content
|
|
||||||
const elements = Array.from({ length: numberOfElements }, (_, i) => ({
|
|
||||||
id: `element-${i}`,
|
|
||||||
label: customLabels[i] || `Element ${i + 1}`,
|
|
||||||
icon: showIcon ? <IconComponent /> : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return <ComponentName {...args} elements={elements} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Dynamic: Story = {
|
|
||||||
render: renderComponent,
|
|
||||||
args: {
|
|
||||||
numberOfElements: 3,
|
|
||||||
showIcon: true,
|
|
||||||
customLabels: ["First", "Second", "Third"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. **State Management**
|
|
||||||
For interactive components:
|
|
||||||
```tsx
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const ComponentWithState = (args: any) => {
|
|
||||||
const [value, setValue] = useState(args.defaultValue);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComponentName
|
|
||||||
{...args}
|
|
||||||
value={value}
|
|
||||||
onChange={(newValue) => {
|
|
||||||
setValue(newValue);
|
|
||||||
args.onChange?.(newValue);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Interactive: Story = {
|
|
||||||
render: ComponentWithState,
|
|
||||||
args: { defaultValue: "initial" },
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. **Quality Requirements**
|
|
||||||
- Include component description in parameters.docs
|
|
||||||
- Add story documentation for non-obvious use cases
|
|
||||||
- Test edge cases (overflow, empty states, many elements)
|
|
||||||
- Ensure no TypeScript errors
|
|
||||||
- Use realistic prop values
|
|
||||||
- Include at least 3-5 story variants
|
|
||||||
- Example values need to be in the context of survey application
|
|
||||||
|
|
||||||
### 9. **Naming Conventions**
|
|
||||||
- **Story titles**: "UI/ComponentName"
|
|
||||||
- **Story exports**: PascalCase (Default, WithIcon, ManyElements)
|
|
||||||
- **Categories**: "Behavior", "Appearance", "Content" (exact spelling)
|
|
||||||
- **Props**: camelCase matching component props
|
|
||||||
|
|
||||||
### 10. **Special Cases**
|
|
||||||
- **Generic components**: Remove `component` from meta if type conflicts
|
|
||||||
- **Form components**: Include Invalid, WithValue stories
|
|
||||||
- **Navigation**: Include ManyItems stories
|
|
||||||
- **Modals, Dropdowns and Popups **: Include trigger and content structure
|
|
||||||
|
|
||||||
## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases.
|
|
||||||
@@ -90,7 +90,7 @@ When testing hooks that use React Context:
|
|||||||
vi.mocked(useResponseFilter).mockReturnValue({
|
vi.mocked(useResponseFilter).mockReturnValue({
|
||||||
selectedFilter: {
|
selectedFilter: {
|
||||||
filter: [],
|
filter: [],
|
||||||
responseStatus: "all",
|
onlyComplete: false,
|
||||||
},
|
},
|
||||||
setSelectedFilter: vi.fn(),
|
setSelectedFilter: vi.fn(),
|
||||||
selectedOptions: {
|
selectedOptions: {
|
||||||
@@ -291,6 +291,11 @@ test("handles different modes", async () => {
|
|||||||
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Test sharing mode
|
||||||
|
vi.mocked(useParams).mockReturnValue({
|
||||||
|
surveyId: "123",
|
||||||
|
sharingKey: "share-123"
|
||||||
|
});
|
||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ SMTP_PASSWORD=smtpPassword
|
|||||||
|
|
||||||
# Uncomment the variables you would like to use and customize the values.
|
# Uncomment the variables you would like to use and customize the values.
|
||||||
|
|
||||||
|
# Custom local storage path for file uploads
|
||||||
|
#UPLOADS_DIR=
|
||||||
|
|
||||||
##############
|
##############
|
||||||
# S3 STORAGE #
|
# S3 STORAGE #
|
||||||
##############
|
##############
|
||||||
@@ -186,11 +189,15 @@ ENTERPRISE_LICENSE_KEY=
|
|||||||
UNSPLASH_ACCESS_KEY=
|
UNSPLASH_ACCESS_KEY=
|
||||||
|
|
||||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
# 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)
|
# 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:
|
# REDIS_HTTP_URL:
|
||||||
|
|
||||||
|
# The below is used for Rate Limiting for management API
|
||||||
|
UNKEY_ROOT_KEY=
|
||||||
|
|
||||||
# INTERCOM_APP_ID=
|
# INTERCOM_APP_ID=
|
||||||
# INTERCOM_SECRET_KEY=
|
# INTERCOM_SECRET_KEY=
|
||||||
|
|
||||||
@@ -212,7 +219,7 @@ REDIS_URL=redis://localhost:6379
|
|||||||
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
|
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
|
||||||
# SESSION_MAX_AGE=86400
|
# SESSION_MAX_AGE=86400
|
||||||
|
|
||||||
# Audit logs options. Default 0.
|
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
|
||||||
# AUDIT_LOG_ENABLED=0
|
# AUDIT_LOG_ENABLED=0
|
||||||
# If the ip should be added in the log or not. Default 0
|
# If the ip should be added in the log or not. Default 0
|
||||||
# AUDIT_LOG_GET_USER_IP=0
|
# AUDIT_LOG_GET_USER_IP=0
|
||||||
|
|||||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,7 +1,6 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
||||||
type: bug
|
type: bug
|
||||||
projects: "formbricks/8"
|
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
blank_issues_enabled: true
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Questions
|
- name: Questions
|
||||||
url: https://github.com/formbricks/formbricks/discussions
|
url: https://github.com/formbricks/formbricks/discussions
|
||||||
|
|||||||
4
.github/actions/cache-build-web/action.yml
vendored
4
.github/actions/cache-build-web/action.yml
vendored
@@ -62,12 +62,10 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||||
env:
|
|
||||||
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
|
|
||||||
run: |
|
run: |
|
||||||
RANDOM_KEY=$(openssl rand -hex 32)
|
RANDOM_KEY=$(openssl rand -hex 32)
|
||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||||
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
|
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
|
|||||||
@@ -1,49 +1,66 @@
|
|||||||
name: "Upload Sentry Sourcemaps"
|
name: 'Upload Sentry Sourcemaps'
|
||||||
description: "Extract sourcemaps from Docker image and upload to Sentry"
|
description: 'Extract sourcemaps from Docker image and upload to Sentry'
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
docker_image:
|
docker_image:
|
||||||
description: "Docker image to extract sourcemaps from"
|
description: 'Docker image to extract sourcemaps from'
|
||||||
required: true
|
required: true
|
||||||
release_version:
|
release_version:
|
||||||
description: "Sentry release version (e.g., v1.2.3)"
|
description: 'Sentry release version (e.g., v1.2.3)'
|
||||||
required: true
|
required: true
|
||||||
sentry_auth_token:
|
sentry_auth_token:
|
||||||
description: "Sentry authentication token"
|
description: 'Sentry authentication token'
|
||||||
required: true
|
required: true
|
||||||
environment:
|
|
||||||
description: "Sentry environment (e.g., production, staging)"
|
|
||||||
required: false
|
|
||||||
default: "staging"
|
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Extract sourcemaps from Docker image
|
- name: Validate Sentry auth token
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
DOCKER_IMAGE: ${{ inputs.docker_image }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
echo "🔐 Validating Sentry authentication token..."
|
||||||
|
|
||||||
# Validate docker image format (basic validation)
|
# Assign token to local variable for secure handling
|
||||||
if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then
|
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
|
||||||
echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'"
|
|
||||||
echo "Provided: ${DOCKER_IMAGE}"
|
# Test the token by making a simple API call to Sentry
|
||||||
|
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
|
||||||
|
-H "Authorization: Bearer $SENTRY_TOKEN" \
|
||||||
|
"https://sentry.io/api/0/organizations/formbricks/")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
|
||||||
|
if [ "$http_code" != "200" ]; then
|
||||||
|
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
|
||||||
|
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
|
||||||
|
if [ -f /tmp/sentry_response.json ]; then
|
||||||
|
echo "Response body:"
|
||||||
|
cat /tmp/sentry_response.json
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Extracting sourcemaps from Docker image: ${DOCKER_IMAGE}"
|
echo "✅ Sentry auth token validated successfully"
|
||||||
|
|
||||||
|
# Clean up temp file
|
||||||
|
rm -f /tmp/sentry_response.json
|
||||||
|
|
||||||
|
- name: Extract sourcemaps from Docker image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
|
||||||
|
|
||||||
# Create temporary container from the image and capture its ID
|
# Create temporary container from the image and capture its ID
|
||||||
echo "Creating temporary container..."
|
echo "Creating temporary container..."
|
||||||
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
|
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
|
||||||
echo "Container created with ID: ${CONTAINER_ID}"
|
echo "Container created with ID: $CONTAINER_ID"
|
||||||
|
|
||||||
# Set up cleanup function to ensure container is removed on script exit
|
# Set up cleanup function to ensure container is removed on script exit
|
||||||
cleanup_container() {
|
cleanup_container() {
|
||||||
@@ -55,13 +72,13 @@ runs:
|
|||||||
# Remove the container if it exists (ignore errors if already removed)
|
# Remove the container if it exists (ignore errors if already removed)
|
||||||
if [ -n "$CONTAINER_ID" ]; then
|
if [ -n "$CONTAINER_ID" ]; then
|
||||||
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
|
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
|
||||||
echo "Container ${CONTAINER_ID} removed"
|
echo "Container $CONTAINER_ID removed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Exit with the original exit code to preserve script success/failure status
|
# Exit with the original exit code to preserve script success/failure status
|
||||||
exit $original_exit_code
|
exit $original_exit_code
|
||||||
}
|
}
|
||||||
|
|
||||||
# Register cleanup function to run on script exit (success or failure)
|
# Register cleanup function to run on script exit (success or failure)
|
||||||
trap cleanup_container EXIT
|
trap cleanup_container EXIT
|
||||||
|
|
||||||
@@ -76,7 +93,7 @@ runs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
|
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
|
||||||
echo "✅ Found ${sourcemap_count} sourcemap files"
|
echo "✅ Found $sourcemap_count sourcemap files"
|
||||||
|
|
||||||
if [ "$sourcemap_count" -eq 0 ]; then
|
if [ "$sourcemap_count" -eq 0 ]; then
|
||||||
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
|
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
|
||||||
@@ -90,9 +107,9 @@ runs:
|
|||||||
SENTRY_ORG: formbricks
|
SENTRY_ORG: formbricks
|
||||||
SENTRY_PROJECT: formbricks-cloud
|
SENTRY_PROJECT: formbricks-cloud
|
||||||
with:
|
with:
|
||||||
environment: ${{ inputs.environment }}
|
environment: production
|
||||||
version: ${{ inputs.release_version }}
|
version: ${{ inputs.release_version }}
|
||||||
sourcemaps: "./extracted-next/"
|
sourcemaps: './extracted-next/'
|
||||||
|
|
||||||
- name: Clean up extracted files
|
- name: Clean up extracted files
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
82
.github/workflows/apply-issue-labels-to-pr.yml
vendored
Normal file
82
.github/workflows/apply-issue-labels-to-pr.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
name: "Apply issue labels to PR"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label_on_pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: none
|
||||||
|
issues: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Apply labels from linked issue to PR
|
||||||
|
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
async function getLinkedIssues(owner, repo, prNumber) {
|
||||||
|
const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
pullRequest(number: $prNumber) {
|
||||||
|
closingIssuesReferences(first: 10) {
|
||||||
|
nodes {
|
||||||
|
number
|
||||||
|
labels(first: 10) {
|
||||||
|
nodes {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
owner: owner,
|
||||||
|
repo: repo,
|
||||||
|
prNumber: prNumber,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await github.graphql(query, variables);
|
||||||
|
return result.repository.pullRequest.closingIssuesReferences.nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pr = context.payload.pull_request;
|
||||||
|
const linkedIssues = await getLinkedIssues(
|
||||||
|
context.repo.owner,
|
||||||
|
context.repo.repo,
|
||||||
|
pr.number
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelsToAdd = new Set();
|
||||||
|
for (const issue of linkedIssues) {
|
||||||
|
if (issue.labels && issue.labels.nodes) {
|
||||||
|
for (const label of issue.labels.nodes) {
|
||||||
|
labelsToAdd.add(label.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labelsToAdd.size) {
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
labels: Array.from(labelsToAdd),
|
||||||
|
});
|
||||||
|
}
|
||||||
99
.github/workflows/build-and-push-ecr.yml
vendored
99
.github/workflows/build-and-push-ecr.yml
vendored
@@ -1,99 +0,0 @@
|
|||||||
name: Build & Push Docker to ECR
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
image_tag:
|
|
||||||
description: "Image tag to push (e.g., v3.16.1)"
|
|
||||||
required: true
|
|
||||||
default: "v3.16.1"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
ECR_REGION: ${{ vars.ECR_REGION }}
|
|
||||||
# ECR settings are sourced from repository/environment variables for portability across envs/forks
|
|
||||||
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
|
|
||||||
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
|
|
||||||
DOCKERFILE: apps/web/Dockerfile
|
|
||||||
CONTEXT: .
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
name: Build and Push
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
|
|
||||||
- name: Validate image tag input
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ -z "${IMAGE_TAG}" ]]; then
|
|
||||||
echo "❌ Image tag is required (non-empty)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if (( ${#IMAGE_TAG} > 128 )); then
|
|
||||||
echo "❌ Image tag must be at most 128 characters."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ ! "${IMAGE_TAG}" =~ ^[a-z0-9._-]+$ ]]; then
|
|
||||||
echo "❌ Image tag may only contain lowercase letters, digits, '.', '_' and '-'."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ "${IMAGE_TAG}" =~ ^[.-] || "${IMAGE_TAG}" =~ [.-]$ ]]; then
|
|
||||||
echo "❌ Image tag must not start or end with '.' or '-'."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Validate required variables
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
|
|
||||||
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
|
|
||||||
ECR_REGION: ${{ env.ECR_REGION }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ -z "${ECR_REGISTRY}" || -z "${ECR_REPOSITORY}" || -z "${ECR_REGION}" ]]; then
|
|
||||||
echo "ECR_REGION, ECR_REGISTRY and ECR_REPOSITORY must be set via repository or environment variables (Settings → Variables)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Configure AWS credentials (OIDC)
|
|
||||||
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
|
|
||||||
with:
|
|
||||||
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
|
|
||||||
aws-region: ${{ env.ECR_REGION }}
|
|
||||||
|
|
||||||
- name: Log in to Amazon ECR
|
|
||||||
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
|
|
||||||
|
|
||||||
- name: Set up Depot CLI
|
|
||||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
|
||||||
|
|
||||||
- name: Build and push image (Depot remote builder)
|
|
||||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
|
||||||
with:
|
|
||||||
project: tw0fqmsx3c
|
|
||||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
|
||||||
context: ${{ env.CONTEXT }}
|
|
||||||
file: ${{ env.DOCKERFILE }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ inputs.image_tag }}
|
|
||||||
${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:latest
|
|
||||||
secrets: |
|
|
||||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
|
||||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
|
||||||
4
.github/workflows/chromatic.yml
vendored
4
.github/workflows/chromatic.yml
vendored
@@ -6,14 +6,12 @@ on:
|
|||||||
- main
|
- main
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
chromatic:
|
chromatic:
|
||||||
name: Run Chromatic
|
name: Run Chromatic
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
actions: read
|
actions: read
|
||||||
|
|||||||
27
.github/workflows/dependency-review.yml
vendored
Normal file
27
.github/workflows/dependency-review.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Dependency Review Action
|
||||||
|
#
|
||||||
|
# This Action will scan dependency manifest files that change as part of a Pull Request,
|
||||||
|
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
|
||||||
|
# Once installed, if the workflow run is marked as required,
|
||||||
|
# PRs introducing known-vulnerable packages will be blocked from merging.
|
||||||
|
#
|
||||||
|
# Source repository: https://github.com/actions/dependency-review-action
|
||||||
|
name: 'Dependency Review'
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dependency-review:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: 'Checkout Repository'
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: 'Dependency Review'
|
||||||
|
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0
|
||||||
35
.github/workflows/deploy-formbricks-cloud.yml
vendored
35
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -17,8 +17,8 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- staging
|
- stage
|
||||||
- production
|
- prod
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
VERSION:
|
VERSION:
|
||||||
@@ -37,27 +37,21 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: read
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
helmfile-deploy:
|
helmfile-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Tailscale
|
- name: Tailscale
|
||||||
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
|
uses: tailscale/github-action@v3
|
||||||
with:
|
with:
|
||||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||||
tags: tag:github
|
tags: tag:github
|
||||||
args: --accept-routes
|
|
||||||
|
|
||||||
- name: Configure AWS Credentials
|
- name: Configure AWS Credentials
|
||||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||||
@@ -71,9 +65,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
AWS_REGION: eu-central-1
|
AWS_REGION: eu-central-1
|
||||||
|
|
||||||
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
|
- uses: helmfile/helmfile-action@v2
|
||||||
name: Deploy Formbricks Cloud Production
|
name: Deploy Formbricks Cloud Prod
|
||||||
if: inputs.ENVIRONMENT == 'production'
|
if: inputs.ENVIRONMENT == 'prod'
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ inputs.VERSION }}
|
VERSION: ${{ inputs.VERSION }}
|
||||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||||
@@ -89,9 +83,9 @@ jobs:
|
|||||||
helmfile-auto-init: "false"
|
helmfile-auto-init: "false"
|
||||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||||
|
|
||||||
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
|
- uses: helmfile/helmfile-action@v2
|
||||||
name: Deploy Formbricks Cloud Staging
|
name: Deploy Formbricks Cloud Stage
|
||||||
if: inputs.ENVIRONMENT == 'staging'
|
if: inputs.ENVIRONMENT == 'stage'
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ inputs.VERSION }}
|
VERSION: ${{ inputs.VERSION }}
|
||||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||||
@@ -107,20 +101,19 @@ jobs:
|
|||||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||||
|
|
||||||
- name: Purge Cloudflare Cache
|
- name: Purge Cloudflare Cache
|
||||||
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
|
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
|
||||||
env:
|
env:
|
||||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
|
|
||||||
run: |
|
run: |
|
||||||
# Set hostname based on environment
|
# Set hostname based on environment
|
||||||
if [[ "$ENVIRONMENT" == "production" ]]; then
|
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
|
||||||
PURGE_HOST="app.formbricks.com"
|
PURGE_HOST="app.formbricks.com"
|
||||||
else
|
else
|
||||||
PURGE_HOST="stage.app.formbricks.com"
|
PURGE_HOST="stage.app.formbricks.com"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
|
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
|
||||||
|
|
||||||
# Prepare JSON payload for selective cache purge
|
# Prepare JSON payload for selective cache purge
|
||||||
json_payload=$(cat << EOF
|
json_payload=$(cat << EOF
|
||||||
|
|||||||
125
.github/workflows/docker-build-validation.yml
vendored
125
.github/workflows/docker-build-validation.yml
vendored
@@ -39,68 +39,42 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
uses: docker/build-push-action@v6
|
||||||
env:
|
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./apps/web/Dockerfile
|
file: ./apps/web/Dockerfile
|
||||||
push: false
|
push: false
|
||||||
load: true
|
load: true
|
||||||
tags: formbricks-test:${{ env.GITHUB_SHA }}
|
tags: formbricks-test:${{ github.sha }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
secrets: |
|
secrets: |
|
||||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
|
|
||||||
- name: Verify and Initialize PostgreSQL
|
- name: Verify PostgreSQL Connection
|
||||||
run: |
|
run: |
|
||||||
echo "Verifying PostgreSQL connection..."
|
echo "Verifying PostgreSQL connection..."
|
||||||
# Install PostgreSQL client to test connection
|
# Install PostgreSQL client to test connection
|
||||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||||
|
|
||||||
# Test connection using psql with timeout and proper error handling
|
# Test connection using psql
|
||||||
echo "Testing PostgreSQL connection with 30 second timeout..."
|
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
|
||||||
if timeout 30 bash -c 'until PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" >/dev/null 2>&1; do
|
|
||||||
echo "Waiting for PostgreSQL to be ready..."
|
|
||||||
sleep 2
|
|
||||||
done'; then
|
|
||||||
echo "✅ PostgreSQL connection successful"
|
|
||||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "SELECT version();"
|
|
||||||
|
|
||||||
# Enable necessary extensions that might be required by migrations
|
|
||||||
echo "Enabling required PostgreSQL extensions..."
|
|
||||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "CREATE EXTENSION IF NOT EXISTS vector;" || echo "Vector extension already exists or not available"
|
|
||||||
|
|
||||||
else
|
|
||||||
echo "❌ PostgreSQL connection failed after 30 seconds"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Show network configuration
|
# Show network configuration
|
||||||
echo "Network configuration:"
|
echo "Network configuration:"
|
||||||
|
ip addr show
|
||||||
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
|
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
|
||||||
|
|
||||||
- name: Test Docker Image with Health Check
|
- name: Test Docker Image with Health Check
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
|
||||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
|
||||||
run: |
|
run: |
|
||||||
echo "🧪 Testing if the Docker image starts correctly..."
|
echo "🧪 Testing if the Docker image starts correctly..."
|
||||||
|
|
||||||
@@ -112,12 +86,29 @@ jobs:
|
|||||||
$DOCKER_RUN_ARGS \
|
$DOCKER_RUN_ARGS \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||||
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
|
||||||
-d "formbricks-test:$GITHUB_SHA"
|
-d formbricks-test:${{ github.sha }}
|
||||||
|
|
||||||
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
|
# Give it more time to start up
|
||||||
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."
|
echo "Waiting 45 seconds for application to start..."
|
||||||
MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes
|
sleep 45
|
||||||
|
|
||||||
|
# Check if the container is running
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
|
||||||
|
echo "❌ Container failed to start properly!"
|
||||||
|
docker logs formbricks-test
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ Container started successfully!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try connecting to PostgreSQL from inside the container
|
||||||
|
echo "Testing PostgreSQL connection from inside container..."
|
||||||
|
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
|
||||||
|
|
||||||
|
# Try to access the health endpoint
|
||||||
|
echo "🏥 Testing /health endpoint..."
|
||||||
|
MAX_RETRIES=10
|
||||||
RETRY_COUNT=0
|
RETRY_COUNT=0
|
||||||
HEALTH_CHECK_SUCCESS=false
|
HEALTH_CHECK_SUCCESS=false
|
||||||
|
|
||||||
@@ -125,32 +116,38 @@ jobs:
|
|||||||
|
|
||||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||||
|
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
|
||||||
# Check if container is still running
|
|
||||||
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then
|
# Show container logs before each attempt to help debugging
|
||||||
echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!"
|
if [ $RETRY_COUNT -gt 1 ]; then
|
||||||
echo "📋 Container logs:"
|
echo "📋 Current container logs:"
|
||||||
docker logs formbricks-test
|
docker logs --tail 20 formbricks-test
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Show progress and diagnostic info every 12 attempts (1 minute intervals)
|
# Get detailed curl output for debugging
|
||||||
if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then
|
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
|
||||||
echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..."
|
CURL_EXIT_CODE=$?
|
||||||
echo "📋 Recent container logs:"
|
|
||||||
docker logs --tail 10 formbricks-test
|
echo "Curl exit code: $CURL_EXIT_CODE"
|
||||||
|
echo "Curl output: $HTTP_OUTPUT"
|
||||||
|
|
||||||
|
if [ $CURL_EXIT_CODE -eq 0 ]; then
|
||||||
|
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
|
||||||
|
echo "Status code detected: $STATUS_CODE"
|
||||||
|
|
||||||
|
if [ "$STATUS_CODE" = "200" ]; then
|
||||||
|
echo "✅ Health check successful!"
|
||||||
|
HEALTH_CHECK_SUCCESS=true
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Try health endpoint with shorter timeout for faster polling
|
echo "Waiting 15 seconds before next attempt..."
|
||||||
# Use -f flag to make curl fail on HTTP error status codes (4xx, 5xx)
|
sleep 15
|
||||||
if curl -f -s -m 10 http://localhost:3000/health >/dev/null 2>&1; then
|
|
||||||
echo "✅ Health check successful after $((RETRY_COUNT * 5)) seconds!"
|
|
||||||
HEALTH_CHECK_SUCCESS=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wait 5 seconds before next attempt
|
|
||||||
sleep 5
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# Show full container logs for debugging
|
# Show full container logs for debugging
|
||||||
@@ -163,7 +160,7 @@ jobs:
|
|||||||
|
|
||||||
# Exit with failure if health check did not succeed
|
# Exit with failure if health check did not succeed
|
||||||
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
|
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
|
||||||
echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)"
|
echo "❌ Health check failed after $MAX_RETRIES attempts"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
70
.github/workflows/docker-security-scan.yml
vendored
70
.github/workflows/docker-security-scan.yml
vendored
@@ -1,70 +0,0 @@
|
|||||||
name: Docker Security Scan
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 2 * * *" # Daily at 2 AM UTC
|
|
||||||
workflow_dispatch:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["Docker Release to Github"]
|
|
||||||
types: [completed]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: read
|
|
||||||
security-events: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
scan:
|
|
||||||
name: Vulnerability Scan
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout (for SARIF fingerprinting only)
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Determine ref and commit for upload
|
|
||||||
id: gitref
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
|
||||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
|
||||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ "${EVENT_NAME}" == "workflow_run" ]]; then
|
|
||||||
echo "ref=refs/heads/${HEAD_BRANCH}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "ref=${GITHUB_REF}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Run Trivy vulnerability scanner
|
|
||||||
uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0
|
|
||||||
with:
|
|
||||||
image-ref: "ghcr.io/${{ github.repository }}:latest"
|
|
||||||
format: "sarif"
|
|
||||||
output: "trivy-results.sarif"
|
|
||||||
severity: "CRITICAL,HIGH,MEDIUM,LOW"
|
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
|
||||||
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
|
|
||||||
if: ${{ always() }}
|
|
||||||
with:
|
|
||||||
sarif_file: "trivy-results.sarif"
|
|
||||||
ref: ${{ steps.gitref.outputs.ref }}
|
|
||||||
sha: ${{ steps.gitref.outputs.sha }}
|
|
||||||
category: "trivy-container-scan"
|
|
||||||
77
.github/workflows/e2e.yml
vendored
77
.github/workflows/e2e.yml
vendored
@@ -55,18 +55,6 @@ jobs:
|
|||||||
--health-interval=10s
|
--health-interval=10s
|
||||||
--health-timeout=5s
|
--health-timeout=5s
|
||||||
--health-retries=5
|
--health-retries=5
|
||||||
minio:
|
|
||||||
image: bitnami/minio:2025.7.23-debian-12-r5
|
|
||||||
env:
|
|
||||||
MINIO_ROOT_USER: minioadmin
|
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
|
||||||
ports:
|
|
||||||
- 9000:9000
|
|
||||||
options: >-
|
|
||||||
--health-cmd="curl -fsS http://localhost:9000/minio/health/live || exit 1"
|
|
||||||
--health-interval=10s
|
|
||||||
--health-timeout=5s
|
|
||||||
--health-retries=20
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
@@ -101,53 +89,10 @@ jobs:
|
|||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||||
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
|
||||||
echo "" >> .env
|
echo "" >> .env
|
||||||
echo "E2E_TESTING=1" >> .env
|
echo "E2E_TESTING=1" >> .env
|
||||||
echo "S3_REGION=us-east-1" >> .env
|
|
||||||
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
|
|
||||||
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
|
|
||||||
echo "S3_ACCESS_KEY=minioadmin" >> .env
|
|
||||||
echo "S3_SECRET_KEY=minioadmin" >> .env
|
|
||||||
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install MinIO client (mc)
|
|
||||||
run: |
|
|
||||||
wget -qO mc https://dl.min.io/client/mc/release/linux-amd64/mc
|
|
||||||
chmod +x mc
|
|
||||||
sudo mv mc /usr/local/bin/mc
|
|
||||||
|
|
||||||
- name: Wait for MinIO and create S3 bucket
|
|
||||||
run: |
|
|
||||||
echo "Waiting for MinIO to be ready..."
|
|
||||||
for i in {1..60}; do
|
|
||||||
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
|
|
||||||
echo "MinIO is up after ${i} seconds"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "Waiting for MinIO... (attempt ${i}/60)"
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# Give MinIO a bit more time to fully initialize
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
echo "Configuring MinIO client..."
|
|
||||||
mc alias set local http://localhost:9000 minioadmin minioadmin
|
|
||||||
|
|
||||||
echo "Creating S3 bucket..."
|
|
||||||
mc mb --ignore-existing local/formbricks-e2e
|
|
||||||
|
|
||||||
echo "Verifying bucket creation..."
|
|
||||||
mc ls local/
|
|
||||||
|
|
||||||
echo "Testing MinIO connectivity..."
|
|
||||||
curl -fsS http://localhost:9000/minio/health/live && echo "✓ MinIO health check passed"
|
|
||||||
|
|
||||||
echo "Testing S3 API endpoint..."
|
|
||||||
curl -fsS http://localhost:9000/ && echo "✓ MinIO S3 API accessible" || echo "✗ MinIO S3 API not accessible"
|
|
||||||
|
|
||||||
- name: Build App
|
- name: Build App
|
||||||
run: |
|
run: |
|
||||||
pnpm build --filter=@formbricks/web...
|
pnpm build --filter=@formbricks/web...
|
||||||
@@ -157,12 +102,6 @@ jobs:
|
|||||||
# pnpm prisma migrate deploy
|
# pnpm prisma migrate deploy
|
||||||
pnpm db:migrate:dev
|
pnpm db:migrate:dev
|
||||||
|
|
||||||
- name: Run Rate Limiter Load Tests
|
|
||||||
run: |
|
|
||||||
echo "Running rate limiter load tests with Redis/Valkey..."
|
|
||||||
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
|
|
||||||
shell: bash
|
|
||||||
|
|
||||||
- name: Check for Enterprise License
|
- name: Check for Enterprise License
|
||||||
run: |
|
run: |
|
||||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||||
@@ -190,22 +129,6 @@ jobs:
|
|||||||
sleep 10
|
sleep 10
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Test Storage and MinIO Integration
|
|
||||||
run: |
|
|
||||||
echo "Testing MinIO file upload with mc client..."
|
|
||||||
# Test file upload using MinIO client
|
|
||||||
echo "test content" > test-file.txt
|
|
||||||
mc cp test-file.txt local/formbricks-e2e/test-file.txt
|
|
||||||
|
|
||||||
echo "Verifying file was uploaded..."
|
|
||||||
mc ls local/formbricks-e2e/
|
|
||||||
|
|
||||||
echo "Testing file download..."
|
|
||||||
mc cp local/formbricks-e2e/test-file.txt downloaded-test-file.txt
|
|
||||||
cat downloaded-test-file.txt
|
|
||||||
|
|
||||||
echo "MinIO integration test completed successfully!"
|
|
||||||
|
|
||||||
- name: Install Playwright
|
- name: Install Playwright
|
||||||
run: pnpm exec playwright install --with-deps
|
run: pnpm exec playwright install --with-deps
|
||||||
|
|
||||||
|
|||||||
34
.github/workflows/formbricks-release.yml
vendored
34
.github/workflows/formbricks-release.yml
vendored
@@ -1,29 +1,20 @@
|
|||||||
name: Build, release & deploy Formbricks images
|
name: Build, release & deploy Formbricks images
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
workflow_dispatch:
|
||||||
types: [published]
|
push:
|
||||||
|
tags:
|
||||||
permissions:
|
- "v*"
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-build:
|
docker-build:
|
||||||
name: Build & release docker image
|
name: Build & release stable docker image
|
||||||
permissions:
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
uses: ./.github/workflows/release-docker-github.yml
|
uses: ./.github/workflows/release-docker-github.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
|
||||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
|
||||||
|
|
||||||
helm-chart-release:
|
helm-chart-release:
|
||||||
name: Release Helm Chart
|
name: Release Helm Chart
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
uses: ./.github/workflows/release-helm-chart.yml
|
uses: ./.github/workflows/release-helm-chart.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
needs:
|
needs:
|
||||||
@@ -33,9 +24,6 @@ jobs:
|
|||||||
|
|
||||||
deploy-formbricks-cloud:
|
deploy-formbricks-cloud:
|
||||||
name: Deploy Helm Chart to Formbricks Cloud
|
name: Deploy Helm Chart to Formbricks Cloud
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
||||||
needs:
|
needs:
|
||||||
@@ -43,7 +31,7 @@ jobs:
|
|||||||
- helm-chart-release
|
- helm-chart-release
|
||||||
with:
|
with:
|
||||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
ENVIRONMENT: "prod"
|
||||||
|
|
||||||
upload-sentry-sourcemaps:
|
upload-sentry-sourcemaps:
|
||||||
name: Upload Sentry Sourcemaps
|
name: Upload Sentry Sourcemaps
|
||||||
@@ -54,13 +42,8 @@ jobs:
|
|||||||
- docker-build
|
- docker-build
|
||||||
- deploy-formbricks-cloud
|
- deploy-formbricks-cloud
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -71,4 +54,3 @@ jobs:
|
|||||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
environment: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
|
||||||
|
|||||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
@@ -10,6 +10,8 @@ permissions:
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
merge_group:
|
merge_group:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ jobs:
|
|||||||
# with sigstore/fulcio when running outside of PRs.
|
# with sigstore/fulcio when running outside of PRs.
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
outputs:
|
|
||||||
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
|
|
||||||
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
@@ -41,55 +37,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Generate SemVer version from branch or tag
|
|
||||||
id: generate_version
|
|
||||||
env:
|
|
||||||
REF_NAME: ${{ github.ref_name }}
|
|
||||||
REF_TYPE: ${{ github.ref_type }}
|
|
||||||
run: |
|
|
||||||
# Get reference name and type from environment variables
|
|
||||||
echo "Reference type: $REF_TYPE"
|
|
||||||
echo "Reference name: $REF_NAME"
|
|
||||||
|
|
||||||
if [[ "$REF_TYPE" == "tag" ]]; then
|
|
||||||
# If running from a tag, use the tag name
|
|
||||||
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
|
||||||
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
|
|
||||||
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
|
|
||||||
echo "Using SemVer tag: $VERSION"
|
|
||||||
else
|
|
||||||
# Tag is not SemVer, treat as prerelease
|
|
||||||
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
|
||||||
VERSION="0.0.0-$SANITIZED_TAG"
|
|
||||||
echo "Using tag as prerelease: $VERSION"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Running from branch, use branch name as prerelease
|
|
||||||
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
|
||||||
VERSION="0.0.0-$SANITIZED_BRANCH"
|
|
||||||
echo "Using branch as prerelease: $VERSION"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
echo "Generated SemVer version: $VERSION"
|
|
||||||
|
|
||||||
- name: Update package.json version
|
|
||||||
run: |
|
|
||||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
|
|
||||||
cat ./apps/web/package.json | grep version
|
|
||||||
|
|
||||||
- name: Set Sentry environment in .env
|
|
||||||
run: |
|
|
||||||
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
|
|
||||||
echo "SENTRY_ENVIRONMENT=staging" >> .env
|
|
||||||
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
|
|
||||||
else
|
|
||||||
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set up Depot CLI
|
- name: Set up Depot CLI
|
||||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||||
@@ -136,21 +83,6 @@ jobs:
|
|||||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
|
|
||||||
- name: Extract image info for sourcemap upload
|
|
||||||
id: extract_image_info
|
|
||||||
run: |
|
|
||||||
# Use the first readable tag from metadata action output
|
|
||||||
DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs)
|
|
||||||
echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# Use the generated version for Sentry release
|
|
||||||
RELEASE_VERSION="$VERSION"
|
|
||||||
echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
echo "Docker image: $DOCKER_IMAGE"
|
|
||||||
echo "Release version: $RELEASE_VERSION"
|
|
||||||
echo "Available tags: ${{ steps.meta.outputs.tags }}"
|
|
||||||
|
|
||||||
# Sign the resulting Docker image digest except on PRs.
|
# Sign the resulting Docker image digest except on PRs.
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
# This will only write to the public Rekor transparency log when the Docker
|
||||||
# repository is public to avoid leaking data. If you would like to publish
|
# repository is public to avoid leaking data. If you would like to publish
|
||||||
@@ -165,30 +97,3 @@ jobs:
|
|||||||
# This step uses the identity token to provision an ephemeral certificate
|
# This step uses the identity token to provision an ephemeral certificate
|
||||||
# against the sigstore community Fulcio instance.
|
# against the sigstore community Fulcio instance.
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||||
|
|
||||||
upload-sentry-sourcemaps:
|
|
||||||
name: Upload Sentry Sourcemaps
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
needs:
|
|
||||||
- build
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Upload Sentry Sourcemaps
|
|
||||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
docker_image: ${{ needs.build.outputs.DOCKER_IMAGE }}
|
|
||||||
release_version: ${{ needs.build.outputs.RELEASE_VERSION }}
|
|
||||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
environment: staging
|
|
||||||
|
|||||||
31
.github/workflows/release-docker-github.yml
vendored
31
.github/workflows/release-docker-github.yml
vendored
@@ -7,12 +7,6 @@ name: Docker Release to Github
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
|
||||||
IS_PRERELEASE:
|
|
||||||
description: "Whether this is a prerelease (affects latest tag)"
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
outputs:
|
outputs:
|
||||||
VERSION:
|
VERSION:
|
||||||
description: release version
|
description: release version
|
||||||
@@ -26,9 +20,6 @@ env:
|
|||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -54,23 +45,10 @@ jobs:
|
|||||||
- name: Get Release Tag
|
- name: Get Release Tag
|
||||||
id: extract_release_tag
|
id: extract_release_tag
|
||||||
run: |
|
run: |
|
||||||
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
|
TAG=${{ github.ref }}
|
||||||
TAG="$GITHUB_REF"
|
|
||||||
TAG=${TAG#refs/tags/v}
|
TAG=${TAG#refs/tags/v}
|
||||||
|
|
||||||
# Validate the extracted tag format
|
|
||||||
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
|
||||||
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
|
|
||||||
echo "Original ref: $GITHUB_REF"
|
|
||||||
echo "Extracted tag: $TAG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Safely add to environment variables
|
|
||||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||||
|
|
||||||
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||||
echo "Using tag-based version: $TAG"
|
|
||||||
|
|
||||||
- name: Update package.json version
|
- name: Update package.json version
|
||||||
run: |
|
run: |
|
||||||
@@ -103,13 +81,6 @@ jobs:
|
|||||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
|
||||||
# Default semver tags (version, major.minor, major)
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
# Only tag as 'latest' for stable releases (not prereleases)
|
|
||||||
type=raw,value=latest,enable=${{ !inputs.IS_PRERELEASE }}
|
|
||||||
|
|
||||||
# Build and push Docker image with Buildx (don't push on PR)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
|
|||||||
30
.github/workflows/release-helm-chart.yml
vendored
30
.github/workflows/release-helm-chart.yml
vendored
@@ -26,23 +26,8 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Validate input version
|
- name: Extract release version
|
||||||
env:
|
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||||
INPUT_VERSION: ${{ inputs.VERSION }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
# Validate input version format (expects clean semver without 'v' prefix)
|
|
||||||
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
|
||||||
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
|
|
||||||
echo "Expected: clean version without 'v' prefix"
|
|
||||||
echo "Provided: $INPUT_VERSION"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Store validated version in environment variable
|
|
||||||
echo "VERSION<<EOF" >> $GITHUB_ENV
|
|
||||||
echo "$INPUT_VERSION" >> $GITHUB_ENV
|
|
||||||
echo "EOF" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Set up Helm
|
- name: Set up Helm
|
||||||
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
||||||
@@ -50,18 +35,15 @@ jobs:
|
|||||||
version: latest
|
version: latest
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
env:
|
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GITHUB_ACTOR: ${{ github.actor }}
|
|
||||||
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
|
|
||||||
|
|
||||||
- name: Install YQ
|
- name: Install YQ
|
||||||
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||||
|
|
||||||
- name: Update Chart.yaml with new version
|
- name: Update Chart.yaml with new version
|
||||||
run: |
|
run: |
|
||||||
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
|
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
||||||
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
|
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
||||||
|
|
||||||
- name: Package Helm chart
|
- name: Package Helm chart
|
||||||
run: |
|
run: |
|
||||||
@@ -69,4 +51,4 @@ jobs:
|
|||||||
|
|
||||||
- name: Push Helm chart to GitHub Container Registry
|
- name: Push Helm chart to GitHub Container Registry
|
||||||
run: |
|
run: |
|
||||||
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
|
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
|
||||||
|
|||||||
81
.github/workflows/scorecard.yml
vendored
Normal file
81
.github/workflows/scorecard.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||||
|
# by a third-party and are governed by separate terms of service, privacy
|
||||||
|
# policy, and support documentation.
|
||||||
|
|
||||||
|
name: Scorecard supply-chain security
|
||||||
|
on:
|
||||||
|
# For Branch-Protection check. Only the default branch is supported. See
|
||||||
|
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||||
|
branch_protection_rule:
|
||||||
|
# To guarantee Maintained check is occasionally updated. See
|
||||||
|
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||||
|
schedule:
|
||||||
|
- cron: "17 17 * * 6"
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Declare default permissions as read only.
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analysis:
|
||||||
|
name: Scorecard analysis
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
# Needed to upload the results to code-scanning dashboard.
|
||||||
|
security-events: write
|
||||||
|
# Needed to publish results and get a badge (see publish_results below).
|
||||||
|
id-token: write
|
||||||
|
# Add this permission
|
||||||
|
actions: write # Required for artifact upload
|
||||||
|
# Uncomment the permissions below if installing in a private repository.
|
||||||
|
# contents: read
|
||||||
|
# actions: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: "Run analysis"
|
||||||
|
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
|
||||||
|
with:
|
||||||
|
results_file: results.sarif
|
||||||
|
results_format: sarif
|
||||||
|
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||||
|
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||||
|
# - you are installing Scorecard on a *private* repository
|
||||||
|
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
|
||||||
|
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||||
|
|
||||||
|
# Public repositories:
|
||||||
|
# - Publish results to OpenSSF REST API for easy access by consumers
|
||||||
|
# - Allows the repository to include the Scorecard badge.
|
||||||
|
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
||||||
|
# For private repositories:
|
||||||
|
# - `publish_results` will always be set to `false`, regardless
|
||||||
|
# of the value entered here.
|
||||||
|
publish_results: true
|
||||||
|
|
||||||
|
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||||
|
# format to the repository Actions tab.
|
||||||
|
- name: "Upload artifact"
|
||||||
|
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||||
|
with:
|
||||||
|
name: sarif
|
||||||
|
path: results.sarif
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
|
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||||
|
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||||
|
- name: "Upload to code-scanning"
|
||||||
|
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||||
|
with:
|
||||||
|
sarif_file: results.sarif
|
||||||
8
.github/workflows/semantic-pull-requests.yml
vendored
8
.github/workflows/semantic-pull-requests.yml
vendored
@@ -56,3 +56,11 @@ jobs:
|
|||||||
```
|
```
|
||||||
${{ steps.lint_pr_title.outputs.error_message }}
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
# Delete a previous comment when the issue has been resolved
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Thank you for following the naming conventions for pull request titles! 🙏
|
||||||
|
|||||||
1
.github/workflows/sonarqube.yml
vendored
1
.github/workflows/sonarqube.yml
vendored
@@ -43,7 +43,6 @@ jobs:
|
|||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "infra/terraform/**"
|
- "infra/terraform/**"
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
terraform:
|
terraform:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -35,7 +33,7 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Tailscale
|
- name: Tailscale
|
||||||
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
|
uses: tailscale/github-action@v3
|
||||||
with:
|
with:
|
||||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||||
|
|||||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -41,7 +41,6 @@ jobs:
|
|||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|||||||
10
.github/workflows/tolgee.yml
vendored
10
.github/workflows/tolgee.yml
vendored
@@ -27,18 +27,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Get source branch name
|
- name: Get source branch name
|
||||||
id: branch-name
|
id: branch-name
|
||||||
env:
|
|
||||||
RAW_BRANCH: ${{ github.head_ref }}
|
|
||||||
run: |
|
run: |
|
||||||
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
|
RAW_BRANCH="${{ github.head_ref }}"
|
||||||
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
|
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
|
||||||
|
|
||||||
# Additional validation - ensure branch name is not empty after sanitization
|
|
||||||
if [[ -z "$SOURCE_BRANCH" ]]; then
|
|
||||||
echo "❌ Error: Branch name is empty after sanitization"
|
|
||||||
echo "Original branch: $RAW_BRANCH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Safely add to environment variables using GitHub's recommended method
|
# Safely add to environment variables using GitHub's recommended method
|
||||||
# This prevents environment variable injection attacks
|
# This prevents environment variable injection attacks
|
||||||
|
|||||||
20
.github/workflows/upload-sentry-sourcemaps.yml
vendored
20
.github/workflows/upload-sentry-sourcemaps.yml
vendored
@@ -23,26 +23,24 @@ jobs:
|
|||||||
upload-sourcemaps:
|
upload-sourcemaps:
|
||||||
name: Upload Sourcemaps to Sentry
|
name: Upload Sourcemaps to Sentry
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set Docker Image
|
- name: Set Docker Image
|
||||||
run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV
|
run: |
|
||||||
env:
|
if [ -n "${{ inputs.tag_version }}" ]; then
|
||||||
DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }}
|
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload Sourcemaps to Sentry
|
- name: Upload Sourcemaps to Sentry
|
||||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||||
with:
|
with:
|
||||||
docker_image: ${{ env.DOCKER_IMAGE }}
|
docker_image: ${{ env.DOCKER_IMAGE }}
|
||||||
release_version: ${{ inputs.release_version }}
|
release_version: ${{ inputs.release_version }}
|
||||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
32
.github/workflows/welcome-new-contributors.yml
vendored
Normal file
32
.github/workflows/welcome-new-contributors.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: "Welcome new contributors"
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: opened
|
||||||
|
pull_request_target:
|
||||||
|
types: opened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
welcome-message:
|
||||||
|
name: Welcoming New Users
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
if: github.event.action == 'opened'
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
pr-message: |-
|
||||||
|
Thank you so much for making your first Pull Request and taking the time to improve Formbricks! 🚀🙏❤️
|
||||||
|
Feel free to join the conversation on [Github Discussions](https://github.com/formbricks/formbricks/discussions) if you need any help or have any questions. 😊
|
||||||
|
issue-message: |
|
||||||
|
Thank you for opening your first issue! 🙏❤️ One of our team members will review it and get back to you as soon as it possible. 😊
|
||||||
@@ -31,10 +31,6 @@
|
|||||||
{
|
{
|
||||||
"language": "pt-PT",
|
"language": "pt-PT",
|
||||||
"path": "./apps/web/locales/pt-PT.json"
|
"path": "./apps/web/locales/pt-PT.json"
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "ro-RO",
|
|
||||||
"path": "./apps/web/locales/ro-RO.json"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"forceMode": "OVERRIDE"
|
"forceMode": "OVERRIDE"
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import type { StorybookConfig } from "@storybook/react-vite";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
import { createRequire } from "module";
|
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function is used to resolve the absolute path of a package.
|
* This function is used to resolve the absolute path of a package.
|
||||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||||
*/
|
*/
|
||||||
function getAbsolutePath(value: string): any {
|
const getAbsolutePath = (value: string) => {
|
||||||
return dirname(require.resolve(join(value, "package.json")));
|
return dirname(require.resolve(join(value, "package.json")));
|
||||||
}
|
};
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-alpine3.22 AS base
|
FROM node:22-alpine3.21 AS base
|
||||||
|
|
||||||
#
|
#
|
||||||
## step 1: Prune monorepo
|
## step 1: Prune monorepo
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "test-redis-url",
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("LandingSidebar component", () => {
|
describe("LandingSidebar component", () => {
|
||||||
const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
|
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
|
||||||
const organization = { id: "o1", name: "orgOne" } as any;
|
const organization = { id: "o1", name: "orgOne" } as any;
|
||||||
const organizations = [
|
const organizations = [
|
||||||
{ id: "o2", name: "betaOrg" },
|
{ id: "o2", name: "betaOrg" },
|
||||||
|
|||||||
@@ -80,25 +80,25 @@ export const LandingSidebar = ({
|
|||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
asChild
|
asChild
|
||||||
id="userDropdownTrigger"
|
id="userDropdownTrigger"
|
||||||
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||||
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center gap-3")}>
|
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
|
||||||
<ProfileAvatar userId={user.id} />
|
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
||||||
<>
|
<>
|
||||||
<div className="grow overflow-hidden">
|
<div>
|
||||||
<p
|
<p
|
||||||
title={user?.email}
|
title={user?.email}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
|
||||||
)}>
|
)}>
|
||||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
title={capitalizeFirstLetter(organization?.name)}
|
title={capitalizeFirstLetter(organization?.name)}
|
||||||
className="truncate text-sm text-slate-500">
|
className="max-w-28 truncate text-sm text-slate-500">
|
||||||
{capitalizeFirstLetter(organization?.name)}
|
{capitalizeFirstLetter(organization?.name)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
|
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "test-redis-url",
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "test-redis-url",
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
WEBAPP_URL: "test-webapp-url",
|
WEBAPP_URL: "test-webapp-url",
|
||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "test-redis-url",
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
WEBAPP_URL: "test-webapp-url",
|
WEBAPP_URL: "test-webapp-url",
|
||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "test-redis-url",
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
|
|||||||
industry: "ind",
|
industry: "ind",
|
||||||
defaultBrandColor: "#fff",
|
defaultBrandColor: "#fff",
|
||||||
organizationTeams: [],
|
organizationTeams: [],
|
||||||
isAccessControlAllowed: false,
|
canDoRoleManagement: false,
|
||||||
userProjectsCount: 0,
|
userProjectsCount: 0,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ interface ProjectSettingsProps {
|
|||||||
industry: TProjectConfigIndustry;
|
industry: TProjectConfigIndustry;
|
||||||
defaultBrandColor: string;
|
defaultBrandColor: string;
|
||||||
organizationTeams: TOrganizationTeam[];
|
organizationTeams: TOrganizationTeam[];
|
||||||
isAccessControlAllowed: boolean;
|
canDoRoleManagement: boolean;
|
||||||
userProjectsCount: number;
|
userProjectsCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export const ProjectSettings = ({
|
|||||||
industry,
|
industry,
|
||||||
defaultBrandColor,
|
defaultBrandColor,
|
||||||
organizationTeams,
|
organizationTeams,
|
||||||
isAccessControlAllowed = false,
|
canDoRoleManagement = false,
|
||||||
userProjectsCount,
|
userProjectsCount,
|
||||||
}: ProjectSettingsProps) => {
|
}: ProjectSettingsProps) => {
|
||||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||||
@@ -174,7 +174,7 @@ export const ProjectSettings = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isAccessControlAllowed && userProjectsCount > 0 && (
|
{canDoRoleManagement && userProjectsCount > 0 && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="teamIds"
|
name="teamIds"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
@@ -12,7 +12,7 @@ vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
|
|||||||
// Mocks before component import
|
// Mocks before component import
|
||||||
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
||||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() }));
|
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
|
||||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||||
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
@@ -61,7 +61,7 @@ describe("ProjectSettingsPage", () => {
|
|||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any);
|
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
|
||||||
|
|
||||||
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
||||||
});
|
});
|
||||||
@@ -73,7 +73,7 @@ describe("ProjectSettingsPage", () => {
|
|||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
|
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||||
|
|
||||||
const element = await Page({ params, searchParams });
|
const element = await Page({ params, searchParams });
|
||||||
render(element as React.ReactElement);
|
render(element as React.ReactElement);
|
||||||
@@ -96,7 +96,7 @@ describe("ProjectSettingsPage", () => {
|
|||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
|
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||||
|
|
||||||
const element = await Page({ params, searchParams });
|
const element = await Page({ params, searchParams });
|
||||||
render(element as React.ReactElement);
|
render(element as React.ReactElement);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
|
|||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
||||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
@@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
|
|
||||||
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||||
|
|
||||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||||
|
|
||||||
if (!organizationTeams) {
|
if (!organizationTeams) {
|
||||||
throw new Error(t("common.organization_teams_not_found"));
|
throw new Error(t("common.organization_teams_not_found"));
|
||||||
@@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
industry={industry}
|
industry={industry}
|
||||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||||
organizationTeams={organizationTeams}
|
organizationTeams={organizationTeams}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
canDoRoleManagement={canDoRoleManagement}
|
||||||
userProjectsCount={projects.length}
|
userProjectsCount={projects.length}
|
||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_POSTHOG_CONFIGURED: true,
|
IS_POSTHOG_CONFIGURED: true,
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "redis://localhost:6379",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
vi.mock("@/lib/env", () => ({
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
|
|||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import {
|
import {
|
||||||
getAccessControlPermission,
|
|
||||||
getOrganizationProjectsLimit,
|
getOrganizationProjectsLimit,
|
||||||
|
getRoleManagementPermission,
|
||||||
} from "@/modules/ee/license-check/lib/utils";
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -58,9 +58,9 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||||
|
|
||||||
if (!isAccessControlAllowed) {
|
if (!canDoRoleManagement) {
|
||||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +71,10 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
alert: {
|
alert: {
|
||||||
...user.notificationSettings?.alert,
|
...user.notificationSettings?.alert,
|
||||||
},
|
},
|
||||||
|
weeklySummary: {
|
||||||
|
...user.notificationSettings?.weeklySummary,
|
||||||
|
[project.id]: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
await updateUser(user.id, {
|
await updateUser(user.id, {
|
||||||
|
|||||||
@@ -24,17 +24,14 @@ export const ActionClassesTable = ({
|
|||||||
otherEnvActionClasses,
|
otherEnvActionClasses,
|
||||||
otherEnvironment,
|
otherEnvironment,
|
||||||
}: ActionClassesTableProps) => {
|
}: ActionClassesTableProps) => {
|
||||||
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
|
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
|
||||||
|
|
||||||
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
|
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
|
||||||
|
|
||||||
const handleOpenActionDetailModalClick = (
|
const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => {
|
||||||
e: React.MouseEvent<HTMLButtonElement>,
|
|
||||||
actionClass: TActionClass
|
|
||||||
) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveActionClass(actionClass);
|
setActiveActionClass(actionClass);
|
||||||
setIsActionDetailModalOpen(true);
|
setActionDetailModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,7 +42,7 @@ export const ActionClassesTable = ({
|
|||||||
{actionClasses.length > 0 ? (
|
{actionClasses.length > 0 ? (
|
||||||
actionClasses.map((actionClass, index) => (
|
actionClasses.map((actionClass, index) => (
|
||||||
<button
|
<button
|
||||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
onClick={(e) => {
|
||||||
handleOpenActionDetailModalClick(e, actionClass);
|
handleOpenActionDetailModalClick(e, actionClass);
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -66,7 +63,7 @@ export const ActionClassesTable = ({
|
|||||||
environmentId={environmentId}
|
environmentId={environmentId}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
open={isActionDetailModalOpen}
|
open={isActionDetailModalOpen}
|
||||||
setOpen={setIsActionDetailModalOpen}
|
setOpen={setActionDetailModalOpen}
|
||||||
actionClasses={actionClasses}
|
actionClasses={actionClasses}
|
||||||
actionClass={activeActionClass}
|
actionClass={activeActionClass}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
|
|||||||
@@ -70,13 +70,15 @@ export const ActionDetailModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalWithTabs
|
<>
|
||||||
open={open}
|
<ModalWithTabs
|
||||||
setOpen={setOpen}
|
open={open}
|
||||||
tabs={tabs}
|
setOpen={setOpen}
|
||||||
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
tabs={tabs}
|
||||||
label={actionClass.name}
|
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||||
description={typeDescription()}
|
label={actionClass.name}
|
||||||
/>
|
description={typeDescription()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,21 +11,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
|||||||
updateActionClassAction: vi.fn(),
|
updateActionClassAction: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock action utils
|
|
||||||
vi.mock("@/modules/survey/editor/lib/action-utils", () => ({
|
|
||||||
useActionClassKeys: vi.fn(() => ["existing-key"]),
|
|
||||||
createActionClassZodResolver: vi.fn(() => vi.fn()),
|
|
||||||
validatePermissions: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock action builder
|
|
||||||
vi.mock("@/modules/survey/editor/lib/action-builder", () => ({
|
|
||||||
buildActionObject: vi.fn((data, environmentId, t) => ({
|
|
||||||
...data,
|
|
||||||
environmentId,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock utils
|
// Mock utils
|
||||||
vi.mock("@/app/lib/actionClass/actionClass", () => ({
|
vi.mock("@/app/lib/actionClass/actionClass", () => ({
|
||||||
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
|
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
|
||||||
@@ -39,7 +24,6 @@ vi.mock("@/modules/ui/components/button", () => ({
|
|||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/code-action-form", () => ({
|
vi.mock("@/modules/ui/components/code-action-form", () => ({
|
||||||
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||||
<div data-testid="code-action-form" data-readonly={isReadOnly}>
|
<div data-testid="code-action-form" data-readonly={isReadOnly}>
|
||||||
@@ -47,7 +31,6 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||||
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
|
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
|
||||||
open ? (
|
open ? (
|
||||||
@@ -60,26 +43,6 @@ vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
|||||||
</div>
|
</div>
|
||||||
) : null,
|
) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/action-name-description-fields", () => ({
|
|
||||||
ActionNameDescriptionFields: ({ isReadOnly, nameInputId, descriptionInputId }: any) => (
|
|
||||||
<div data-testid="action-name-description-fields">
|
|
||||||
<input
|
|
||||||
data-testid={`name-input-${nameInputId}`}
|
|
||||||
placeholder="environments.actions.eg_clicked_download"
|
|
||||||
disabled={isReadOnly}
|
|
||||||
defaultValue="Test Action"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
data-testid={`description-input-${descriptionInputId}`}
|
|
||||||
placeholder="environments.actions.user_clicked_download_button"
|
|
||||||
disabled={isReadOnly}
|
|
||||||
defaultValue="Test Description"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
|
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
|
||||||
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||||
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
|
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
|
||||||
@@ -93,23 +56,6 @@ vi.mock("lucide-react", () => ({
|
|||||||
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
|
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock react-hook-form
|
|
||||||
const mockHandleSubmit = vi.fn();
|
|
||||||
const mockForm = {
|
|
||||||
handleSubmit: mockHandleSubmit,
|
|
||||||
control: {},
|
|
||||||
formState: { errors: {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("react-hook-form", async () => {
|
|
||||||
const actual = await vi.importActual("react-hook-form");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useForm: vi.fn(() => mockForm),
|
|
||||||
FormProvider: ({ children }: any) => <div>{children}</div>,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockSetOpen = vi.fn();
|
const mockSetOpen = vi.fn();
|
||||||
const mockActionClasses: TActionClass[] = [
|
const mockActionClasses: TActionClass[] = [
|
||||||
{
|
{
|
||||||
@@ -142,7 +88,6 @@ const createMockActionClass = (id: string, type: TActionClassType, name: string)
|
|||||||
describe("ActionSettingsTab", () => {
|
describe("ActionSettingsTab", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockHandleSubmit.mockImplementation((fn) => fn);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -160,9 +105,13 @@ describe("ActionSettingsTab", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
|
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
|
||||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
|
actionClass.name
|
||||||
|
);
|
||||||
|
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
|
||||||
|
actionClass.description
|
||||||
|
);
|
||||||
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
|
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
|
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
|
||||||
@@ -182,104 +131,18 @@ describe("ActionSettingsTab", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
|
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||||
|
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
|
||||||
|
actionClass.name
|
||||||
|
);
|
||||||
|
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
|
||||||
|
actionClass.description
|
||||||
|
);
|
||||||
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
|
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders correctly for other action types (fallback)", () => {
|
|
||||||
const actionClass = {
|
|
||||||
...createMockActionClass("auto1", "noCode", "Auto Action"),
|
|
||||||
type: "automatic" as any,
|
|
||||||
};
|
|
||||||
render(
|
|
||||||
<ActionSettingsTab
|
|
||||||
actionClass={actionClass}
|
|
||||||
actionClasses={mockActionClasses}
|
|
||||||
setOpen={mockSetOpen}
|
|
||||||
isReadOnly={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText(
|
|
||||||
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
|
|
||||||
)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls utility functions on initialization", async () => {
|
|
||||||
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
|
|
||||||
|
|
||||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
|
||||||
render(
|
|
||||||
<ActionSettingsTab
|
|
||||||
actionClass={actionClass}
|
|
||||||
actionClasses={mockActionClasses}
|
|
||||||
setOpen={mockSetOpen}
|
|
||||||
isReadOnly={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
|
|
||||||
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles successful form submission", async () => {
|
|
||||||
const { updateActionClassAction } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
|
||||||
);
|
|
||||||
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
|
|
||||||
|
|
||||||
vi.mocked(updateActionClassAction).mockResolvedValue({ data: {} } as any);
|
|
||||||
|
|
||||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
|
||||||
render(
|
|
||||||
<ActionSettingsTab
|
|
||||||
actionClass={actionClass}
|
|
||||||
actionClasses={mockActionClasses}
|
|
||||||
setOpen={mockSetOpen}
|
|
||||||
isReadOnly={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check that utility functions were called during component initialization
|
|
||||||
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
|
|
||||||
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles permission validation error", async () => {
|
|
||||||
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
|
|
||||||
vi.mocked(actionUtilsMock.validatePermissions).mockImplementation(() => {
|
|
||||||
throw new Error("Not authorized");
|
|
||||||
});
|
|
||||||
|
|
||||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
|
||||||
render(
|
|
||||||
<ActionSettingsTab
|
|
||||||
actionClass={actionClass}
|
|
||||||
actionClasses={mockActionClasses}
|
|
||||||
setOpen={mockSetOpen}
|
|
||||||
isReadOnly={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitButton = screen.getByRole("button", { name: "common.save_changes" });
|
|
||||||
|
|
||||||
mockHandleSubmit.mockImplementation((fn) => (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
return fn({ name: "Test", type: "noCode" });
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(submitButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("Not authorized");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles successful deletion", async () => {
|
test("handles successful deletion", async () => {
|
||||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||||
const { deleteActionClassAction } = await import(
|
const { deleteActionClassAction } = await import(
|
||||||
@@ -346,16 +209,17 @@ describe("ActionSettingsTab", () => {
|
|||||||
actionClass={actionClass}
|
actionClass={actionClass}
|
||||||
actionClasses={mockActionClasses}
|
actionClasses={mockActionClasses}
|
||||||
setOpen={mockSetOpen}
|
setOpen={mockSetOpen}
|
||||||
isReadOnly={true}
|
isReadOnly={true} // Set to read-only
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeDisabled();
|
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeDisabled();
|
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
|
||||||
|
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
|
||||||
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
|
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
|
||||||
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
|
||||||
});
|
});
|
||||||
|
|
||||||
test("prevents delete when read-only", async () => {
|
test("prevents delete when read-only", async () => {
|
||||||
@@ -364,6 +228,7 @@ describe("ActionSettingsTab", () => {
|
|||||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
"@/app/(app)/environments/[environmentId]/actions/actions"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Render with isReadOnly=true, but simulate a delete attempt
|
||||||
render(
|
render(
|
||||||
<ActionSettingsTab
|
<ActionSettingsTab
|
||||||
actionClass={actionClass}
|
actionClass={actionClass}
|
||||||
@@ -373,6 +238,12 @@ describe("ActionSettingsTab", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
|
||||||
|
// This test primarily checks the logic within handleDeleteAction if it were called.
|
||||||
|
// A better approach might be to export handleDeleteAction for direct testing,
|
||||||
|
// but for now, we assume the UI prevents calling it.
|
||||||
|
|
||||||
|
// We can assert that the delete button isn't there to prevent the flow
|
||||||
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
||||||
expect(deleteActionClassAction).not.toHaveBeenCalled();
|
expect(deleteActionClassAction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -391,19 +262,4 @@ describe("ActionSettingsTab", () => {
|
|||||||
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
|
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
|
||||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses correct input IDs for ActionNameDescriptionFields", () => {
|
|
||||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
|
||||||
render(
|
|
||||||
<ActionSettingsTab
|
|
||||||
actionClass={actionClass}
|
|
||||||
actionClasses={mockActionClasses}
|
|
||||||
setOpen={mockSetOpen}
|
|
||||||
isReadOnly={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,17 +4,14 @@ import {
|
|||||||
deleteActionClassAction,
|
deleteActionClassAction,
|
||||||
updateActionClassAction,
|
updateActionClassAction,
|
||||||
} from "@/app/(app)/environments/[environmentId]/actions/actions";
|
} from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||||
import { buildActionObject } from "@/modules/survey/editor/lib/action-builder";
|
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
|
||||||
import {
|
|
||||||
createActionClassZodResolver,
|
|
||||||
useActionClassKeys,
|
|
||||||
validatePermissions,
|
|
||||||
} from "@/modules/survey/editor/lib/action-utils";
|
|
||||||
import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { CodeActionForm } from "@/modules/ui/components/code-action-form";
|
import { CodeActionForm } from "@/modules/ui/components/code-action-form";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
|
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
|
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { TrashIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -22,7 +19,8 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
|
import { z } from "zod";
|
||||||
|
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||||
|
|
||||||
interface ActionSettingsTabProps {
|
interface ActionSettingsTabProps {
|
||||||
actionClass: TActionClass;
|
actionClass: TActionClass;
|
||||||
@@ -50,51 +48,63 @@ export const ActionSettingsTab = ({
|
|||||||
[actionClass.id, actionClasses]
|
[actionClass.id, actionClasses]
|
||||||
);
|
);
|
||||||
|
|
||||||
const actionClassKeys = useActionClassKeys(actionClasses);
|
|
||||||
|
|
||||||
const form = useForm<TActionClassInput>({
|
const form = useForm<TActionClassInput>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
...restActionClass,
|
...restActionClass,
|
||||||
},
|
},
|
||||||
resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t),
|
resolver: zodResolver(
|
||||||
|
ZActionClassInput.superRefine((data, ctx) => {
|
||||||
|
if (data.name && actionClassNames.includes(data.name)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["name"],
|
||||||
|
message: t("environments.actions.action_with_name_already_exists", { name: data.name }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, control } = form;
|
const { handleSubmit, control } = form;
|
||||||
|
|
||||||
const renderActionForm = () => {
|
|
||||||
if (actionClass.type === "code") {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CodeActionForm form={form} isReadOnly={true} />
|
|
||||||
<p className="text-sm text-slate-600">
|
|
||||||
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actionClass.type === "noCode") {
|
|
||||||
return <NoCodeActionForm form={form} isReadOnly={isReadOnly} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-slate-600">
|
|
||||||
{t("environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it")}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (data: TActionClassInput) => {
|
const onSubmit = async (data: TActionClassInput) => {
|
||||||
try {
|
try {
|
||||||
|
if (isReadOnly) {
|
||||||
|
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
|
||||||
|
}
|
||||||
setIsUpdatingAction(true);
|
setIsUpdatingAction(true);
|
||||||
validatePermissions(isReadOnly, t);
|
|
||||||
const updatedAction = buildActionObject(data, actionClass.environmentId, t);
|
|
||||||
|
|
||||||
|
if (data.name && actionClassNames.includes(data.name)) {
|
||||||
|
throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.type === "noCode" &&
|
||||||
|
data.noCodeConfig?.type === "click" &&
|
||||||
|
data.noCodeConfig.elementSelector.cssSelector &&
|
||||||
|
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
|
||||||
|
) {
|
||||||
|
throw new Error(t("environments.actions.invalid_css_selector"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedData: TActionClassInput = {
|
||||||
|
...data,
|
||||||
|
...(data.type === "noCode" &&
|
||||||
|
data.noCodeConfig?.type === "click" && {
|
||||||
|
noCodeConfig: {
|
||||||
|
...data.noCodeConfig,
|
||||||
|
elementSelector: {
|
||||||
|
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
|
||||||
|
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
await updateActionClassAction({
|
await updateActionClassAction({
|
||||||
actionClassId: actionClass.id,
|
actionClassId: actionClass.id,
|
||||||
updatedAction: updatedAction,
|
updatedAction: updatedData,
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@@ -113,7 +123,7 @@ export const ActionSettingsTab = ({
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
toast.success(t("environments.actions.action_deleted_successfully"));
|
toast.success(t("environments.actions.action_deleted_successfully"));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeletingAction(false);
|
setIsDeletingAction(false);
|
||||||
@@ -125,14 +135,79 @@ export const ActionSettingsTab = ({
|
|||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
|
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
|
||||||
<ActionNameDescriptionFields
|
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||||
control={control}
|
<div className="col-span-1">
|
||||||
isReadOnly={isReadOnly}
|
<FormField
|
||||||
nameInputId="actionNameSettingsInput"
|
control={control}
|
||||||
descriptionInputId="actionDescriptionSettingsInput"
|
name="name"
|
||||||
/>
|
disabled={isReadOnly}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="actionNameSettingsInput">
|
||||||
|
{actionClass.type === "noCode"
|
||||||
|
? t("environments.actions.what_did_your_user_do")
|
||||||
|
: t("environments.actions.display_name")}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
{renderActionForm()}
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="actionNameSettingsInput"
|
||||||
|
{...field}
|
||||||
|
placeholder={t("environments.actions.eg_clicked_download")}
|
||||||
|
isInvalid={!!error?.message}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormError />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-1">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="actionDescriptionSettingsInput">
|
||||||
|
{t("common.description")}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="actionDescriptionSettingsInput"
|
||||||
|
{...field}
|
||||||
|
placeholder={t("environments.actions.user_clicked_download_button")}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionClass.type === "code" ? (
|
||||||
|
<>
|
||||||
|
<CodeActionForm form={form} isReadOnly={true} />
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : actionClass.type === "noCode" ? (
|
||||||
|
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{t(
|
||||||
|
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
||||||
|
|||||||
@@ -9,12 +9,8 @@ import {
|
|||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import {
|
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
getAccessControlPermission,
|
|
||||||
getOrganizationProjectsLimit,
|
|
||||||
} from "@/modules/ee/license-check/lib/utils";
|
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||||
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
@@ -53,14 +49,10 @@ vi.mock("@/lib/membership/utils", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||||
getOrganizationProjectsLimit: vi.fn(),
|
getOrganizationProjectsLimit: vi.fn(),
|
||||||
getAccessControlPermission: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||||
getProjectPermissionByUserId: vi.fn(),
|
getProjectPermissionByUserId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
|
|
||||||
getTeamsByOrganizationId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/tolgee/server", () => ({
|
vi.mock("@/tolgee/server", () => ({
|
||||||
getTranslate: async () => (key: string) => key,
|
getTranslate: async () => (key: string) => key,
|
||||||
}));
|
}));
|
||||||
@@ -79,13 +71,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
|
|
||||||
// Mock components
|
// Mock components
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
||||||
MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => (
|
MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>,
|
||||||
<div data-testid="main-navigation">
|
|
||||||
MainNavigation
|
|
||||||
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
|
|
||||||
<div data-testid="is-access-control-allowed">{isAccessControlAllowed?.toString() || "false"}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
||||||
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
||||||
@@ -113,11 +99,12 @@ const mockUser = {
|
|||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
|
imageUrl: "",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
notificationSettings: { alert: {} },
|
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
|
|
||||||
const mockOrganization = {
|
const mockOrganization = {
|
||||||
@@ -169,17 +156,6 @@ const mockProjectPermission = {
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const mockOrganizationTeams = [
|
|
||||||
{
|
|
||||||
id: "team-1",
|
|
||||||
name: "Development Team",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "team-2",
|
|
||||||
name: "Marketing Team",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockSession: Session = {
|
const mockSession: Session = {
|
||||||
user: {
|
user: {
|
||||||
id: "user-1",
|
id: "user-1",
|
||||||
@@ -200,8 +176,6 @@ describe("EnvironmentLayout", () => {
|
|||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
|
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
|
|
||||||
mockIsDevelopment = false;
|
mockIsDevelopment = false;
|
||||||
mockIsFormbricksCloud = false;
|
mockIsFormbricksCloud = false;
|
||||||
});
|
});
|
||||||
@@ -314,110 +288,6 @@ describe("EnvironmentLayout", () => {
|
|||||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("passes isAccessControlAllowed props to MainNavigation", async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
await EnvironmentLayout({
|
|
||||||
environmentId: "env-1",
|
|
||||||
session: mockSession,
|
|
||||||
children: <div>Child Content</div>,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
|
|
||||||
expect(vi.mocked(getAccessControlPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles empty organizationTeams array", async () => {
|
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
await EnvironmentLayout({
|
|
||||||
environmentId: "env-1",
|
|
||||||
session: mockSession,
|
|
||||||
children: <div>Child Content</div>,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles null organizationTeams", async () => {
|
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(null);
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
await EnvironmentLayout({
|
|
||||||
environmentId: "env-1",
|
|
||||||
session: mockSession,
|
|
||||||
children: <div>Child Content</div>,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles isAccessControlAllowed false", async () => {
|
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
await EnvironmentLayout({
|
|
||||||
environmentId: "env-1",
|
|
||||||
session: mockSession,
|
|
||||||
children: <div>Child Content</div>,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error if user not found", async () => {
|
test("throws error if user not found", async () => {
|
||||||
vi.mocked(getUser).mockResolvedValue(null);
|
vi.mocked(getUser).mockResolvedValue(null);
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ import {
|
|||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||||
import {
|
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
getAccessControlPermission,
|
|
||||||
getOrganizationProjectsLimit,
|
|
||||||
} from "@/modules/ee/license-check/lib/utils";
|
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||||
@@ -51,10 +48,9 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
throw new Error(t("common.environment_not_found"));
|
throw new Error(t("common.environment_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [projects, environments, isAccessControlAllowed] = await Promise.all([
|
const [projects, environments] = await Promise.all([
|
||||||
getUserProjects(user.id, organization.id),
|
getUserProjects(user.id, organization.id),
|
||||||
getEnvironments(environment.projectId),
|
getEnvironments(environment.projectId),
|
||||||
getAccessControlPermission(organization.billing.plan),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!projects || !environments || !organizations) {
|
if (!projects || !environments || !organizations) {
|
||||||
@@ -105,7 +101,6 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
isPendingDowngrade={isPendingDowngrade ?? false}
|
isPendingDowngrade={isPendingDowngrade ?? false}
|
||||||
active={active}
|
active={active}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
locale={user.locale}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
@@ -121,16 +116,15 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
membershipRole={membershipRole}
|
membershipRole={membershipRole}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
isLicenseActive={active}
|
isLicenseActive={active}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
/>
|
/>
|
||||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
|
||||||
<TopControlBar
|
<TopControlBar
|
||||||
environment={environment}
|
environment={environment}
|
||||||
environments={environments}
|
environments={environments}
|
||||||
membershipRole={membershipRole}
|
membershipRole={membershipRole}
|
||||||
projectPermission={projectPermission}
|
projectPermission={projectPermission}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
<div className="mt-14">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
|
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
@@ -53,19 +52,9 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
|||||||
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/projects/components/project-switcher", () => ({
|
vi.mock("@/modules/projects/components/project-switcher", () => ({
|
||||||
ProjectSwitcher: ({
|
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
|
||||||
isCollapsed,
|
|
||||||
organizationTeams,
|
|
||||||
isAccessControlAllowed,
|
|
||||||
}: {
|
|
||||||
isCollapsed: boolean;
|
|
||||||
organizationTeams: TOrganizationTeam[];
|
|
||||||
isAccessControlAllowed: boolean;
|
|
||||||
}) => (
|
|
||||||
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
|
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
|
||||||
Project Switcher
|
Project Switcher
|
||||||
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
|
|
||||||
<div data-testid="is-access-control-allowed">{isAccessControlAllowed.toString()}</div>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@@ -111,12 +100,13 @@ const mockUser = {
|
|||||||
id: "user1",
|
id: "user1",
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
|
imageUrl: "http://example.com/avatar.png",
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
notificationSettings: { alert: {} },
|
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
objective: "other",
|
objective: "other",
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
@@ -156,7 +146,6 @@ const defaultProps = {
|
|||||||
membershipRole: "owner" as const,
|
membershipRole: "owner" as const,
|
||||||
organizationProjectsLimit: 5,
|
organizationProjectsLimit: 5,
|
||||||
isLicenseActive: true,
|
isLicenseActive: true,
|
||||||
isAccessControlAllowed: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("MainNavigation", () => {
|
describe("MainNavigation", () => {
|
||||||
@@ -345,23 +334,4 @@ describe("MainNavigation", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
|
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
|
|
||||||
render(<MainNavigation {...defaultProps} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
|
|
||||||
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles no organizationTeams", () => {
|
|
||||||
render(<MainNavigation {...defaultProps} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles isAccessControlAllowed false", () => {
|
|
||||||
render(<MainNavigation {...defaultProps} isAccessControlAllowed={false} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ interface NavigationProps {
|
|||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
organizationProjectsLimit: number;
|
organizationProjectsLimit: number;
|
||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
isAccessControlAllowed: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
@@ -81,7 +80,6 @@ export const MainNavigation = ({
|
|||||||
organizationProjectsLimit,
|
organizationProjectsLimit,
|
||||||
isLicenseActive,
|
isLicenseActive,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
isAccessControlAllowed,
|
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -325,7 +323,6 @@ export const MainNavigation = ({
|
|||||||
isTextVisible={isTextVisible}
|
isTextVisible={isTextVisible}
|
||||||
organization={organization}
|
organization={organization}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -339,30 +336,27 @@ export const MainNavigation = ({
|
|||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer flex-row items-center gap-3",
|
"flex cursor-pointer flex-row items-center space-x-3",
|
||||||
isCollapsed ? "justify-center px-2" : "px-4"
|
isCollapsed ? "pl-2" : "pl-4"
|
||||||
)}>
|
)}>
|
||||||
<ProfileAvatar userId={user.id} />
|
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
||||||
{!isCollapsed && !isTextVisible && (
|
{!isCollapsed && !isTextVisible && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}>
|
||||||
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
|
||||||
<p
|
<p
|
||||||
title={user?.email}
|
title={user?.email}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
|
||||||
)}>
|
)}>
|
||||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
title={capitalizeFirstLetter(organization?.name)}
|
title={capitalizeFirstLetter(organization?.name)}
|
||||||
className="truncate text-sm text-slate-500">
|
className="max-w-28 truncate text-sm text-slate-500">
|
||||||
{capitalizeFirstLetter(organization?.name)}
|
{capitalizeFirstLetter(organization?.name)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRightIcon
|
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
|
||||||
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const TestComponent = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div data-testid="responseStatus">{selectedFilter.responseStatus}</div>
|
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div>
|
||||||
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
|
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
|
||||||
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
|
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
|
||||||
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
|
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
|
||||||
@@ -44,7 +44,7 @@ const TestComponent = () => {
|
|||||||
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
|
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
responseStatus: "complete",
|
onlyComplete: true,
|
||||||
})
|
})
|
||||||
}>
|
}>
|
||||||
Update Filter
|
Update Filter
|
||||||
@@ -81,7 +81,7 @@ describe("ResponseFilterContext", () => {
|
|||||||
</ResponseFilterProvider>
|
</ResponseFilterProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("responseStatus").textContent).toBe("all");
|
expect(screen.getByTestId("onlyComplete").textContent).toBe("false");
|
||||||
expect(screen.getByTestId("filterLength").textContent).toBe("0");
|
expect(screen.getByTestId("filterLength").textContent).toBe("0");
|
||||||
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
|
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
|
||||||
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
|
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
|
||||||
@@ -99,7 +99,7 @@ describe("ResponseFilterContext", () => {
|
|||||||
const updateButton = screen.getByText("Update Filter");
|
const updateButton = screen.getByText("Update Filter");
|
||||||
await userEvent.click(updateButton);
|
await userEvent.click(updateButton);
|
||||||
|
|
||||||
expect(screen.getByTestId("responseStatus").textContent).toBe("complete");
|
expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
|
||||||
expect(screen.getByTestId("filterLength").textContent).toBe("1");
|
expect(screen.getByTestId("filterLength").textContent).toBe("1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ export interface FilterValue {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TResponseStatus = "all" | "complete" | "partial";
|
|
||||||
|
|
||||||
export interface SelectedFilterValue {
|
export interface SelectedFilterValue {
|
||||||
filter: FilterValue[];
|
filter: FilterValue[];
|
||||||
responseStatus: TResponseStatus;
|
onlyComplete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectedFilterOptions {
|
interface SelectedFilterOptions {
|
||||||
@@ -49,7 +47,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
|||||||
// state holds the filter selected value
|
// state holds the filter selected value
|
||||||
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
|
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
|
||||||
filter: [],
|
filter: [],
|
||||||
responseStatus: "all",
|
onlyComplete: false,
|
||||||
});
|
});
|
||||||
// state holds all the options of the responses fetched
|
// state holds all the options of the responses fetched
|
||||||
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
||||||
@@ -69,7 +67,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
|||||||
});
|
});
|
||||||
setSelectedFilter({
|
setSelectedFilter({
|
||||||
filter: [],
|
filter: [],
|
||||||
responseStatus: "all",
|
onlyComplete: false,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ describe("TopControlBar", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check if the main div is rendered
|
// Check if the main div is rendered
|
||||||
const mainDiv = screen.getByTestId("fb__global-top-control-bar");
|
const mainDiv = screen.getByTestId("top-control-buttons").parentElement?.parentElement?.parentElement;
|
||||||
expect(mainDiv).toHaveClass("flex h-14 w-full items-center justify-end bg-slate-50 px-6");
|
expect(mainDiv).toHaveClass(
|
||||||
|
"fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"
|
||||||
|
);
|
||||||
|
|
||||||
// Check if the mocked child component is rendered
|
// Check if the mocked child component is rendered
|
||||||
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();
|
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ export const TopControlBar = ({
|
|||||||
projectPermission,
|
projectPermission,
|
||||||
}: SideBarProps) => {
|
}: SideBarProps) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
|
||||||
className="flex h-14 w-full items-center justify-end bg-slate-50 px-6"
|
|
||||||
data-testid="fb__global-top-control-bar">
|
|
||||||
<div className="shadow-xs z-10">
|
<div className="shadow-xs z-10">
|
||||||
<div className="flex w-fit items-center space-x-2 py-2">
|
<div className="flex w-fit items-center space-x-2 py-2">
|
||||||
<TopControlButtons
|
<TopControlButtons
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { EnvironmentContextWrapper, useEnvironment } from "./environment-context";
|
|
||||||
|
|
||||||
// Mock environment data
|
|
||||||
const mockEnvironment: TEnvironment = {
|
|
||||||
id: "test-env-id",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
type: "development",
|
|
||||||
projectId: "test-project-id",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock project data
|
|
||||||
const mockProject = {
|
|
||||||
id: "test-project-id",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
organizationId: "test-org-id",
|
|
||||||
config: {
|
|
||||||
channel: "app",
|
|
||||||
industry: "saas",
|
|
||||||
},
|
|
||||||
linkSurveyBranding: true,
|
|
||||||
styling: {
|
|
||||||
allowStyleOverwrite: true,
|
|
||||||
brandColor: {
|
|
||||||
light: "#ffffff",
|
|
||||||
dark: "#000000",
|
|
||||||
},
|
|
||||||
questionColor: {
|
|
||||||
light: "#000000",
|
|
||||||
dark: "#ffffff",
|
|
||||||
},
|
|
||||||
inputColor: {
|
|
||||||
light: "#000000",
|
|
||||||
dark: "#ffffff",
|
|
||||||
},
|
|
||||||
inputBorderColor: {
|
|
||||||
light: "#cccccc",
|
|
||||||
dark: "#444444",
|
|
||||||
},
|
|
||||||
cardBackgroundColor: {
|
|
||||||
light: "#ffffff",
|
|
||||||
dark: "#000000",
|
|
||||||
},
|
|
||||||
cardBorderColor: {
|
|
||||||
light: "#cccccc",
|
|
||||||
dark: "#444444",
|
|
||||||
},
|
|
||||||
isDarkModeEnabled: false,
|
|
||||||
isLogoHidden: false,
|
|
||||||
hideProgressBar: false,
|
|
||||||
roundness: 8,
|
|
||||||
cardArrangement: {
|
|
||||||
linkSurveys: "casual",
|
|
||||||
appSurveys: "casual",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
recontactDays: 30,
|
|
||||||
inAppSurveyBranding: true,
|
|
||||||
logo: {
|
|
||||||
url: "test-logo.png",
|
|
||||||
bgColor: "#ffffff",
|
|
||||||
},
|
|
||||||
placement: "bottomRight",
|
|
||||||
clickOutsideClose: true,
|
|
||||||
} as TProject;
|
|
||||||
|
|
||||||
// Test component that uses the hook
|
|
||||||
const TestComponent = () => {
|
|
||||||
const { environment, project } = useEnvironment();
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div data-testid="environment-id">{environment.id}</div>
|
|
||||||
<div data-testid="environment-type">{environment.type}</div>
|
|
||||||
<div data-testid="project-id">{project.id}</div>
|
|
||||||
<div data-testid="project-organization-id">{project.organizationId}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("EnvironmentContext", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("provides environment and project data to child components", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
|
||||||
<TestComponent />
|
|
||||||
</EnvironmentContextWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
|
|
||||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
|
|
||||||
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
|
|
||||||
expect(screen.getByTestId("project-organization-id")).toHaveTextContent("test-org-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error when useEnvironment is used outside of provider", () => {
|
|
||||||
const TestComponentWithoutProvider = () => {
|
|
||||||
useEnvironment();
|
|
||||||
return <div>Should not render</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
render(<TestComponentWithoutProvider />);
|
|
||||||
}).toThrow("useEnvironment must be used within an EnvironmentProvider");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("updates context value when environment or project changes", () => {
|
|
||||||
const { rerender } = render(
|
|
||||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
|
||||||
<TestComponent />
|
|
||||||
</EnvironmentContextWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
|
|
||||||
|
|
||||||
const updatedEnvironment = {
|
|
||||||
...mockEnvironment,
|
|
||||||
type: "production" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<EnvironmentContextWrapper environment={updatedEnvironment} project={mockProject}>
|
|
||||||
<TestComponent />
|
|
||||||
</EnvironmentContextWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("production");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("memoizes context value correctly", () => {
|
|
||||||
const { rerender } = render(
|
|
||||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
|
||||||
<TestComponent />
|
|
||||||
</EnvironmentContextWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-render with same props
|
|
||||||
rerender(
|
|
||||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
|
||||||
<TestComponent />
|
|
||||||
</EnvironmentContextWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should still work correctly
|
|
||||||
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
|
|
||||||
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { createContext, useContext, useMemo } from "react";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
|
|
||||||
export interface EnvironmentContextType {
|
|
||||||
environment: TEnvironment;
|
|
||||||
project: TProject;
|
|
||||||
organizationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
|
||||||
|
|
||||||
export const useEnvironment = () => {
|
|
||||||
const context = useContext(EnvironmentContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useEnvironment must be used within an EnvironmentProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Client wrapper component to be used in server components
|
|
||||||
interface EnvironmentContextWrapperProps {
|
|
||||||
environment: TEnvironment;
|
|
||||||
project: TProject;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EnvironmentContextWrapper = ({
|
|
||||||
environment,
|
|
||||||
project,
|
|
||||||
children,
|
|
||||||
}: EnvironmentContextWrapperProps) => {
|
|
||||||
const environmentContextValue = useMemo(
|
|
||||||
() => ({
|
|
||||||
environment,
|
|
||||||
project,
|
|
||||||
organizationId: project.organizationId,
|
|
||||||
}),
|
|
||||||
[environment, project]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
|
|||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tableHeaders = [
|
||||||
|
"common.survey",
|
||||||
|
"environments.integrations.airtable.table_name",
|
||||||
|
"common.questions",
|
||||||
|
"common.updated_at",
|
||||||
|
];
|
||||||
|
|
||||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||||
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
const tableHeaders = [
|
|
||||||
t("common.survey"),
|
|
||||||
t("environments.integrations.airtable.table_name"),
|
|
||||||
t("common.questions"),
|
|
||||||
t("common.updated_at"),
|
|
||||||
];
|
|
||||||
const [isDeleting, setisDeleting] = useState(false);
|
const [isDeleting, setisDeleting] = useState(false);
|
||||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||||
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
|
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
|
||||||
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
|||||||
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||||
{tableHeaders.map((header) => (
|
{tableHeaders.map((header) => (
|
||||||
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
||||||
{header}
|
{t(header)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "test-redis-url",
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ const surveys: TSurvey[] = [
|
|||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
hiddenFields: { enabled: true, fieldIds: [] },
|
hiddenFields: { enabled: true, fieldIds: [] },
|
||||||
pin: null,
|
pin: null,
|
||||||
|
resultShareKey: null,
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
{
|
{
|
||||||
@@ -257,6 +258,7 @@ const surveys: TSurvey[] = [
|
|||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
hiddenFields: { enabled: true, fieldIds: [] },
|
hiddenFields: { enabled: true, fieldIds: [] },
|
||||||
pin: null,
|
pin: null,
|
||||||
|
resultShareKey: null,
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ const mockSurveys: TSurvey[] = [
|
|||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
languages: [],
|
languages: [],
|
||||||
pin: null,
|
pin: null,
|
||||||
|
resultShareKey: null,
|
||||||
segment: null,
|
segment: null,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
styling: null,
|
styling: null,
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ const surveys: TSurvey[] = [
|
|||||||
languages: [],
|
languages: [],
|
||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
pin: null,
|
pin: null,
|
||||||
|
resultShareKey: null,
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
{
|
{
|
||||||
@@ -271,6 +272,7 @@ const surveys: TSurvey[] = [
|
|||||||
languages: [],
|
languages: [],
|
||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
pin: null,
|
pin: null,
|
||||||
|
resultShareKey: null,
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "mock-redis-url",
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ const mockSurveys: TSurvey[] = [
|
|||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
languages: [],
|
languages: [],
|
||||||
pin: null,
|
pin: null,
|
||||||
|
resultShareKey: null,
|
||||||
segment: null,
|
segment: null,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
styling: null,
|
styling: null,
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ const surveys: TSurvey[] = [
|
|||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
hiddenFields: { enabled: true, fieldIds: [] },
|
hiddenFields: { enabled: true, fieldIds: [] },
|
||||||
pin: null,
|
pin: null,
|
||||||
|
resultShareKey: null,
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
{
|
{
|
||||||
@@ -263,6 +264,7 @@ const surveys: TSurvey[] = [
|
|||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
hiddenFields: { enabled: true, fieldIds: [] },
|
hiddenFields: { enabled: true, fieldIds: [] },
|
||||||
pin: null,
|
pin: null,
|
||||||
|
resultShareKey: null,
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ const mockSurveys: TSurvey[] = [
|
|||||||
languages: [],
|
languages: [],
|
||||||
styling: null,
|
styling: null,
|
||||||
segment: null,
|
segment: null,
|
||||||
|
resultShareKey: null,
|
||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
closeOnDate: null,
|
closeOnDate: null,
|
||||||
runOnDate: null,
|
runOnDate: null,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { getEnvironment } from "@/lib/environment/service";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
@@ -6,7 +5,6 @@ import { cleanup, render, screen } from "@testing-library/react";
|
|||||||
import { Session } from "next-auth";
|
import { Session } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TMembership } from "@formbricks/types/memberships";
|
import { TMembership } from "@formbricks/types/memberships";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
@@ -15,20 +13,12 @@ import EnvLayout from "./layout";
|
|||||||
|
|
||||||
// Mock sub-components to render identifiable elements
|
// Mock sub-components to render identifiable elements
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
|
||||||
EnvironmentLayout: ({ children, environmentId, session }: any) => (
|
EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>,
|
||||||
<div data-testid="EnvironmentLayout" data-environment-id={environmentId} data-session={session?.user?.id}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
||||||
EnvironmentIdBaseLayout: ({ children, environmentId, session, user, organization }: any) => (
|
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
|
||||||
<div
|
<div data-testid="EnvironmentIdBaseLayout">
|
||||||
data-testid="EnvironmentIdBaseLayout"
|
{environmentId}
|
||||||
data-environment-id={environmentId}
|
|
||||||
data-session={session?.user?.id}
|
|
||||||
data-user={user?.id}
|
|
||||||
data-organization={organization?.id}>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -37,24 +27,7 @@ vi.mock("@/modules/ui/components/toaster-client", () => ({
|
|||||||
ToasterClient: () => <div data-testid="ToasterClient" />,
|
ToasterClient: () => <div data-testid="ToasterClient" />,
|
||||||
}));
|
}));
|
||||||
vi.mock("./components/EnvironmentStorageHandler", () => ({
|
vi.mock("./components/EnvironmentStorageHandler", () => ({
|
||||||
default: ({ environmentId }: any) => (
|
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
|
||||||
<div data-testid="EnvironmentStorageHandler" data-environment-id={environmentId} />
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({
|
|
||||||
EnvironmentContextWrapper: ({ children, environment, project }: any) => (
|
|
||||||
<div
|
|
||||||
data-testid="EnvironmentContextWrapper"
|
|
||||||
data-environment-id={environment?.id}
|
|
||||||
data-project-id={project?.id}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock navigation
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
redirect: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mocks for dependencies
|
// Mocks for dependencies
|
||||||
@@ -64,43 +37,26 @@ vi.mock("@/modules/environments/lib/utils", () => ({
|
|||||||
vi.mock("@/lib/project/service", () => ({
|
vi.mock("@/lib/project/service", () => ({
|
||||||
getProjectByEnvironmentId: vi.fn(),
|
getProjectByEnvironmentId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/environment/service", () => ({
|
|
||||||
getEnvironment: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/membership/service", () => ({
|
vi.mock("@/lib/membership/service", () => ({
|
||||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("EnvLayout", () => {
|
describe("EnvLayout", () => {
|
||||||
const mockSession = { user: { id: "user1" } } as Session;
|
|
||||||
const mockUser = { id: "user1", email: "user1@example.com" } as TUser;
|
|
||||||
const mockOrganization = { id: "org1", name: "Org1", billing: {} } as TOrganization;
|
|
||||||
const mockProject = { id: "proj1", name: "Test Project" } as TProject;
|
|
||||||
const mockEnvironment = { id: "env1", type: "production" } as TEnvironment;
|
|
||||||
const mockMembership = {
|
|
||||||
id: "member1",
|
|
||||||
role: "owner",
|
|
||||||
organizationId: "org1",
|
|
||||||
userId: "user1",
|
|
||||||
accepted: true,
|
|
||||||
} as TMembership;
|
|
||||||
const mockTranslation = ((key: string) => key) as any;
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders successfully when all dependencies return valid data", async () => {
|
test("renders successfully when all dependencies return valid data", async () => {
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||||
t: mockTranslation,
|
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
|
||||||
session: mockSession,
|
session: { user: { id: "user1" } } as Session,
|
||||||
user: mockUser,
|
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||||
organization: mockOrganization,
|
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||||
});
|
});
|
||||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
|
id: "member1",
|
||||||
|
} as unknown as TMembership);
|
||||||
|
|
||||||
const result = await EnvLayout({
|
const result = await EnvLayout({
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
params: Promise.resolve({ environmentId: "env1" }),
|
||||||
@@ -108,43 +64,56 @@ describe("EnvLayout", () => {
|
|||||||
});
|
});
|
||||||
render(result);
|
render(result);
|
||||||
|
|
||||||
// Verify main layout structure
|
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
||||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toBeInTheDocument();
|
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
|
||||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-environment-id", "env1");
|
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
|
||||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-session", "user1");
|
|
||||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-user", "user1");
|
|
||||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-organization", "org1");
|
|
||||||
|
|
||||||
// Verify environment storage handler
|
|
||||||
expect(screen.getByTestId("EnvironmentStorageHandler")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveAttribute("data-environment-id", "env1");
|
|
||||||
|
|
||||||
// Verify context wrapper
|
|
||||||
expect(screen.getByTestId("EnvironmentContextWrapper")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-environment-id", "env1");
|
|
||||||
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-project-id", "proj1");
|
|
||||||
|
|
||||||
// Verify environment layout
|
|
||||||
expect(screen.getByTestId("EnvironmentLayout")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("EnvironmentLayout")).toHaveAttribute("data-environment-id", "env1");
|
|
||||||
expect(screen.getByTestId("EnvironmentLayout")).toHaveAttribute("data-session", "user1");
|
|
||||||
|
|
||||||
// Verify children are rendered
|
|
||||||
expect(screen.getByTestId("child")).toHaveTextContent("Content");
|
expect(screen.getByTestId("child")).toHaveTextContent("Content");
|
||||||
|
|
||||||
// Verify all services were called with correct parameters
|
|
||||||
expect(environmentIdLayoutChecks).toHaveBeenCalledWith("env1");
|
|
||||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
|
|
||||||
expect(getEnvironment).toHaveBeenCalledWith("env1");
|
|
||||||
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("redirects when session is null", async () => {
|
test("throws error if project is not found", async () => {
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||||
t: mockTranslation,
|
t: ((key: string) => key) as any,
|
||||||
session: null as unknown as Session,
|
session: { user: { id: "user1" } } as Session,
|
||||||
user: mockUser,
|
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||||
organization: mockOrganization,
|
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||||
|
});
|
||||||
|
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
|
||||||
|
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
|
||||||
|
id: "member1",
|
||||||
|
} as unknown as TMembership);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EnvLayout({
|
||||||
|
params: Promise.resolve({ environmentId: "env1" }),
|
||||||
|
children: <div>Content</div>,
|
||||||
|
})
|
||||||
|
).rejects.toThrow("common.project_not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws error if membership is not found", async () => {
|
||||||
|
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||||
|
t: ((key: string) => key) as any,
|
||||||
|
session: { user: { id: "user1" } } as Session,
|
||||||
|
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||||
|
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||||
|
});
|
||||||
|
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
|
||||||
|
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
EnvLayout({
|
||||||
|
params: Promise.resolve({ environmentId: "env1" }),
|
||||||
|
children: <div>Content</div>,
|
||||||
|
})
|
||||||
|
).rejects.toThrow("common.membership_not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calls redirect when session is null", async () => {
|
||||||
|
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||||
|
t: ((key: string) => key) as any,
|
||||||
|
session: undefined as unknown as Session,
|
||||||
|
user: undefined as unknown as TUser,
|
||||||
|
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||||
});
|
});
|
||||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||||
throw new Error("Redirect called");
|
throw new Error("Redirect called");
|
||||||
@@ -156,16 +125,18 @@ describe("EnvLayout", () => {
|
|||||||
children: <div>Content</div>,
|
children: <div>Content</div>,
|
||||||
})
|
})
|
||||||
).rejects.toThrow("Redirect called");
|
).rejects.toThrow("Redirect called");
|
||||||
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws error if user is null", async () => {
|
test("throws error if user is null", async () => {
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||||
t: mockTranslation,
|
t: ((key: string) => key) as any,
|
||||||
session: mockSession,
|
session: { user: { id: "user1" } } as Session,
|
||||||
user: null as unknown as TUser,
|
user: undefined as unknown as TUser,
|
||||||
organization: mockOrganization,
|
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||||
|
throw new Error("Redirect called");
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -174,154 +145,5 @@ describe("EnvLayout", () => {
|
|||||||
children: <div>Content</div>,
|
children: <div>Content</div>,
|
||||||
})
|
})
|
||||||
).rejects.toThrow("common.user_not_found");
|
).rejects.toThrow("common.user_not_found");
|
||||||
|
|
||||||
// Verify redirect was not called
|
|
||||||
expect(redirect).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error if project is not found", async () => {
|
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
|
||||||
t: mockTranslation,
|
|
||||||
session: mockSession,
|
|
||||||
user: mockUser,
|
|
||||||
organization: mockOrganization,
|
|
||||||
});
|
|
||||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
EnvLayout({
|
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
|
||||||
children: <div>Content</div>,
|
|
||||||
})
|
|
||||||
).rejects.toThrow("common.project_not_found");
|
|
||||||
|
|
||||||
// Verify both project and environment were called in Promise.all
|
|
||||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
|
|
||||||
expect(getEnvironment).toHaveBeenCalledWith("env1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error if environment is not found", async () => {
|
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
|
||||||
t: mockTranslation,
|
|
||||||
session: mockSession,
|
|
||||||
user: mockUser,
|
|
||||||
organization: mockOrganization,
|
|
||||||
});
|
|
||||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
EnvLayout({
|
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
|
||||||
children: <div>Content</div>,
|
|
||||||
})
|
|
||||||
).rejects.toThrow("common.environment_not_found");
|
|
||||||
|
|
||||||
// Verify both project and environment were called in Promise.all
|
|
||||||
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
|
|
||||||
expect(getEnvironment).toHaveBeenCalledWith("env1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error if membership is not found", async () => {
|
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
|
||||||
t: mockTranslation,
|
|
||||||
session: mockSession,
|
|
||||||
user: mockUser,
|
|
||||||
organization: mockOrganization,
|
|
||||||
});
|
|
||||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
EnvLayout({
|
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
|
||||||
children: <div>Content</div>,
|
|
||||||
})
|
|
||||||
).rejects.toThrow("common.membership_not_found");
|
|
||||||
|
|
||||||
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles Promise.all correctly for project and environment", async () => {
|
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
|
||||||
t: mockTranslation,
|
|
||||||
session: mockSession,
|
|
||||||
user: mockUser,
|
|
||||||
organization: mockOrganization,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock Promise.all to verify it's called correctly
|
|
||||||
const getProjectSpy = vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
|
||||||
const getEnvironmentSpy = vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
|
|
||||||
|
|
||||||
const result = await EnvLayout({
|
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
|
||||||
children: <div data-testid="child">Content</div>,
|
|
||||||
});
|
|
||||||
render(result);
|
|
||||||
|
|
||||||
// Verify both calls were made
|
|
||||||
expect(getProjectSpy).toHaveBeenCalledWith("env1");
|
|
||||||
expect(getEnvironmentSpy).toHaveBeenCalledWith("env1");
|
|
||||||
|
|
||||||
// Verify successful rendering
|
|
||||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles different environment types correctly", async () => {
|
|
||||||
const developmentEnvironment = { id: "env1", type: "development" } as TEnvironment;
|
|
||||||
|
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
|
||||||
t: mockTranslation,
|
|
||||||
session: mockSession,
|
|
||||||
user: mockUser,
|
|
||||||
organization: mockOrganization,
|
|
||||||
});
|
|
||||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce(developmentEnvironment);
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
|
|
||||||
|
|
||||||
const result = await EnvLayout({
|
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
|
||||||
children: <div data-testid="child">Content</div>,
|
|
||||||
});
|
|
||||||
render(result);
|
|
||||||
|
|
||||||
// Verify context wrapper receives the development environment
|
|
||||||
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-environment-id", "env1");
|
|
||||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles different user roles correctly", async () => {
|
|
||||||
const memberMembership = {
|
|
||||||
id: "member1",
|
|
||||||
role: "member",
|
|
||||||
organizationId: "org1",
|
|
||||||
userId: "user1",
|
|
||||||
accepted: true,
|
|
||||||
} as TMembership;
|
|
||||||
|
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
|
||||||
t: mockTranslation,
|
|
||||||
session: mockSession,
|
|
||||||
user: mockUser,
|
|
||||||
organization: mockOrganization,
|
|
||||||
});
|
|
||||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(memberMembership);
|
|
||||||
|
|
||||||
const result = await EnvLayout({
|
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
|
||||||
children: <div data-testid="child">Content</div>,
|
|
||||||
});
|
|
||||||
render(result);
|
|
||||||
|
|
||||||
// Verify successful rendering with member role
|
|
||||||
expect(screen.getByTestId("child")).toBeInTheDocument();
|
|
||||||
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
@@ -13,6 +11,7 @@ const EnvLayout = async (props: {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||||
@@ -25,19 +24,11 @@ const EnvLayout = async (props: {
|
|||||||
throw new Error(t("common.user_not_found"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [project, environment] = await Promise.all([
|
const project = await getProjectByEnvironmentId(params.environmentId);
|
||||||
getProjectByEnvironmentId(params.environmentId),
|
|
||||||
getEnvironment(params.environmentId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.project_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!environment) {
|
|
||||||
throw new Error(t("common.environment_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
@@ -51,11 +42,9 @@ const EnvLayout = async (props: {
|
|||||||
user={user}
|
user={user}
|
||||||
organization={organization}>
|
organization={organization}>
|
||||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||||
<EnvironmentContextWrapper environment={environment} project={project}>
|
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
{children}
|
||||||
{children}
|
</EnvironmentLayout>
|
||||||
</EnvironmentLayout>
|
|
||||||
</EnvironmentContextWrapper>
|
|
||||||
</EnvironmentIdBaseLayout>
|
</EnvironmentIdBaseLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe("EnvironmentPage", () => {
|
|||||||
id: mockUserId,
|
id: mockUserId,
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
|
imageUrl: "",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "test-redis-url",
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "redis://localhost:6379",
|
||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "redis://localhost:6379",
|
||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "redis://localhost:6379",
|
||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "redis://localhost:6379",
|
||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "test-redis-url",
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: undefined,
|
REDIS_URL: "test-redis-url",
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const mockUser = {
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
|
weeklySummary: {},
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import { Membership } from "../types";
|
||||||
|
import { EditWeeklySummary } from "./EditWeeklySummary";
|
||||||
|
|
||||||
|
vi.mock("lucide-react", () => ({
|
||||||
|
UsersIcon: () => <div data-testid="users-icon" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/link", () => ({
|
||||||
|
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||||
|
<a href={href} data-testid="link">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockNotificationSwitch = vi.fn();
|
||||||
|
vi.mock("./NotificationSwitch", () => ({
|
||||||
|
NotificationSwitch: (props: any) => {
|
||||||
|
mockNotificationSwitch(props);
|
||||||
|
return (
|
||||||
|
<div data-testid={`notification-switch-${props.surveyOrProjectOrOrganizationId}`}>
|
||||||
|
NotificationSwitch
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockT = vi.fn((key) => key);
|
||||||
|
vi.mock("@tolgee/react", () => ({
|
||||||
|
useTranslate: () => ({
|
||||||
|
t: mockT,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUser = {
|
||||||
|
id: "user1",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
notificationSettings: {
|
||||||
|
alert: {},
|
||||||
|
weeklySummary: {
|
||||||
|
proj1: true,
|
||||||
|
proj3: false,
|
||||||
|
},
|
||||||
|
unsubscribedOrganizationIds: [],
|
||||||
|
},
|
||||||
|
role: "project_manager",
|
||||||
|
objective: "other",
|
||||||
|
emailVerified: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
identityProvider: "email",
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
} as unknown as TUser;
|
||||||
|
|
||||||
|
const mockMemberships: Membership[] = [
|
||||||
|
{
|
||||||
|
organization: {
|
||||||
|
id: "org1",
|
||||||
|
name: "Organization 1",
|
||||||
|
projects: [
|
||||||
|
{ id: "proj1", name: "Project 1", environments: [] },
|
||||||
|
{ id: "proj2", name: "Project 2", environments: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
organization: {
|
||||||
|
id: "org2",
|
||||||
|
name: "Organization 2",
|
||||||
|
projects: [{ id: "proj3", name: "Project 3", environments: [] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const environmentId = "test-env-id";
|
||||||
|
|
||||||
|
describe("EditWeeklySummary", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders correctly with multiple memberships and projects", () => {
|
||||||
|
render(<EditWeeklySummary memberships={mockMemberships} user={mockUser} environmentId={environmentId} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Organization 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Project 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Project 2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Organization 2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Project 3")).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(mockNotificationSwitch).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
surveyOrProjectOrOrganizationId: "proj1",
|
||||||
|
notificationSettings: mockUser.notificationSettings,
|
||||||
|
notificationType: "weeklySummary",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("notification-switch-proj1")).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(mockNotificationSwitch).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
surveyOrProjectOrOrganizationId: "proj2",
|
||||||
|
notificationSettings: mockUser.notificationSettings,
|
||||||
|
notificationType: "weeklySummary",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("notification-switch-proj2")).toBeInTheDocument();
|
||||||
|
|
||||||
|
expect(mockNotificationSwitch).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
surveyOrProjectOrOrganizationId: "proj3",
|
||||||
|
notificationSettings: mockUser.notificationSettings,
|
||||||
|
notificationType: "weeklySummary",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("notification-switch-proj3")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const inviteLinks = screen.getAllByTestId("link");
|
||||||
|
expect(inviteLinks.length).toBe(mockMemberships.length);
|
||||||
|
inviteLinks.forEach((link) => {
|
||||||
|
expect(link).toHaveAttribute("href", `/environments/${environmentId}/settings/general`);
|
||||||
|
expect(link).toHaveTextContent("common.invite_them");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByTestId("users-icon").length).toBe(mockMemberships.length);
|
||||||
|
|
||||||
|
expect(screen.getAllByText("common.project")[0]).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("common.weekly_summary")[0]).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getAllByText("environments.settings.notifications.want_to_loop_in_organization_mates?").length
|
||||||
|
).toBe(mockMemberships.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders correctly with no memberships", () => {
|
||||||
|
render(<EditWeeklySummary memberships={[]} user={mockUser} environmentId={environmentId} />);
|
||||||
|
expect(screen.queryByText("Organization 1")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("users-icon")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders correctly when an organization has no projects", () => {
|
||||||
|
const membershipsWithNoProjects: Membership[] = [
|
||||||
|
{
|
||||||
|
organization: {
|
||||||
|
id: "org3",
|
||||||
|
name: "Organization No Projects",
|
||||||
|
projects: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<EditWeeklySummary
|
||||||
|
memberships={membershipsWithNoProjects}
|
||||||
|
user={mockUser}
|
||||||
|
environmentId={environmentId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Organization No Projects")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Project 1")).not.toBeInTheDocument(); // Check that no projects are listed under it
|
||||||
|
expect(mockNotificationSwitch).not.toHaveBeenCalled(); // No projects, so no switches for projects
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslate } from "@tolgee/react";
|
||||||
|
import { UsersIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import { Membership } from "../types";
|
||||||
|
import { NotificationSwitch } from "./NotificationSwitch";
|
||||||
|
|
||||||
|
interface EditAlertsProps {
|
||||||
|
memberships: Membership[];
|
||||||
|
user: TUser;
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAlertsProps) => {
|
||||||
|
const { t } = useTranslate();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{memberships.map((membership) => (
|
||||||
|
<div key={membership.organization.id}>
|
||||||
|
<div className="mb-5 flex items-center space-x-3 text-sm font-medium">
|
||||||
|
<UsersIcon className="h-6 w-7 text-slate-600" />
|
||||||
|
|
||||||
|
<p className="text-slate-800">{membership.organization.name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 rounded-lg border border-slate-200">
|
||||||
|
<div className="grid h-12 grid-cols-3 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
|
||||||
|
<div className="col-span-2">{t("common.project")}</div>
|
||||||
|
<div className="col-span-1 text-center">{t("common.weekly_summary")}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 p-2">
|
||||||
|
{membership.organization.projects.map((project) => (
|
||||||
|
<div
|
||||||
|
className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center justify-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
|
||||||
|
key={project.id}>
|
||||||
|
<div className="col-span-2">{project?.name}</div>
|
||||||
|
<div className="col-span-1 flex items-center justify-center">
|
||||||
|
<NotificationSwitch
|
||||||
|
surveyOrProjectOrOrganizationId={project.id}
|
||||||
|
notificationSettings={user.notificationSettings!}
|
||||||
|
notificationType={"weeklySummary"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="pb-3 pl-4 text-xs text-slate-400">
|
||||||
|
{t("environments.settings.notifications.want_to_loop_in_organization_mates")}?{" "}
|
||||||
|
<Link className="font-semibold" href={`/environments/${environmentId}/settings/general`}>
|
||||||
|
{t("common.invite_them")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,6 +29,7 @@ const organizationId = "org1";
|
|||||||
|
|
||||||
const baseNotificationSettings: TUserNotificationSettings = {
|
const baseNotificationSettings: TUserNotificationSettings = {
|
||||||
alert: {},
|
alert: {},
|
||||||
|
weeklySummary: {},
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +68,19 @@ describe("NotificationSwitch", () => {
|
|||||||
expect(switchInput.checked).toBe(false);
|
expect(switchInput.checked).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders with initial checked state for 'weeklySummary' (true)", () => {
|
||||||
|
const settings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
|
||||||
|
renderSwitch({
|
||||||
|
surveyOrProjectOrOrganizationId: projectId,
|
||||||
|
notificationSettings: settings,
|
||||||
|
notificationType: "weeklySummary",
|
||||||
|
});
|
||||||
|
const switchInput = screen.getByLabelText(
|
||||||
|
"toggle notification settings for weeklySummary"
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(switchInput.checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => {
|
test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => {
|
||||||
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
|
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
|
||||||
renderSwitch({
|
renderSwitch({
|
||||||
@@ -254,6 +268,31 @@ describe("NotificationSwitch", () => {
|
|||||||
expect(toast.success).not.toHaveBeenCalled();
|
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 () => {
|
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
|
||||||
const mockErrorResponse = { serverError: "Permission denied" };
|
const mockErrorResponse = { serverError: "Permission denied" };
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { updateNotificationSettingsAction } from "../actions";
|
|||||||
interface NotificationSwitchProps {
|
interface NotificationSwitchProps {
|
||||||
surveyOrProjectOrOrganizationId: string;
|
surveyOrProjectOrOrganizationId: string;
|
||||||
notificationSettings: TUserNotificationSettings;
|
notificationSettings: TUserNotificationSettings;
|
||||||
notificationType: "alert" | "unsubscribedOrganizationIds";
|
notificationType: "alert" | "weeklySummary" | "unsubscribedOrganizationIds";
|
||||||
autoDisableNotificationType?: string;
|
autoDisableNotificationType?: string;
|
||||||
autoDisableNotificationElementId?: string;
|
autoDisableNotificationElementId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,5 +34,17 @@ describe("Loading Notifications Settings", () => {
|
|||||||
.getByText("environments.settings.notifications.email_alerts_surveys")
|
.getByText("environments.settings.notifications.email_alerts_surveys")
|
||||||
.closest("div[class*='rounded-xl']"); // Find parent card
|
.closest("div[class*='rounded-xl']"); // Find parent card
|
||||||
expect(alertsCard).toBeInTheDocument();
|
expect(alertsCard).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for Weekly Summary LoadingCard
|
||||||
|
expect(
|
||||||
|
screen.getByText("environments.settings.notifications.weekly_summary_projects")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const weeklySummaryCard = screen
|
||||||
|
.getByText("environments.settings.notifications.weekly_summary_projects")
|
||||||
|
.closest("div[class*='rounded-xl']"); // Find parent card
|
||||||
|
expect(weeklySummaryCard).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ const Loading = () => {
|
|||||||
description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
|
description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
|
||||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t("environments.settings.notifications.weekly_summary_projects"),
|
||||||
|
description: t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday"),
|
||||||
|
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { EditAlerts } from "./components/EditAlerts";
|
import { EditAlerts } from "./components/EditAlerts";
|
||||||
|
import { EditWeeklySummary } from "./components/EditWeeklySummary";
|
||||||
import Page from "./page";
|
import Page from "./page";
|
||||||
import { Membership } from "./types";
|
import { Membership } from "./types";
|
||||||
|
|
||||||
@@ -57,7 +58,9 @@ vi.mock("@formbricks/database", () => ({
|
|||||||
vi.mock("./components/EditAlerts", () => ({
|
vi.mock("./components/EditAlerts", () => ({
|
||||||
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
|
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("./components/EditWeeklySummary", () => ({
|
||||||
|
EditWeeklySummary: vi.fn(() => <div>EditWeeklySummaryComponent</div>),
|
||||||
|
}));
|
||||||
vi.mock("./components/IntegrationsTip", () => ({
|
vi.mock("./components/IntegrationsTip", () => ({
|
||||||
IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
|
IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
|
||||||
}));
|
}));
|
||||||
@@ -68,6 +71,7 @@ const mockUser: Partial<TUser> = {
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: { "survey-old": true },
|
alert: { "survey-old": true },
|
||||||
|
weeklySummary: { "project-old": true },
|
||||||
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -133,6 +137,13 @@ describe("NotificationsPage", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
||||||
expect(screen.getByText("IntegrationsTipComponent")).toBeInTheDocument();
|
expect(screen.getByText("IntegrationsTipComponent")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("environments.settings.notifications.weekly_summary_projects")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
|
||||||
|
|
||||||
// The actual `user.notificationSettings` passed to EditAlerts will be a new object
|
// The actual `user.notificationSettings` passed to EditAlerts will be a new object
|
||||||
// after `setCompleteNotificationSettings` processes it.
|
// after `setCompleteNotificationSettings` processes it.
|
||||||
@@ -146,12 +157,16 @@ describe("NotificationsPage", () => {
|
|||||||
// It iterates memberships, then projects, then environments, then surveys.
|
// It iterates memberships, then projects, then environments, then surveys.
|
||||||
// `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;`
|
// `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;`
|
||||||
// This means only survey IDs found in memberships will be in the new `alert` object.
|
// This means only survey IDs found in memberships will be in the new `alert` object.
|
||||||
|
// `newNotificationSettings.weeklySummary[project.id]` also only adds project IDs from memberships.
|
||||||
|
|
||||||
const finalExpectedSettings = {
|
const finalExpectedSettings = {
|
||||||
alert: {
|
alert: {
|
||||||
"survey-1": false,
|
"survey-1": false,
|
||||||
"survey-2": false,
|
"survey-2": false,
|
||||||
},
|
},
|
||||||
|
weeklySummary: {
|
||||||
|
"project-1": false,
|
||||||
|
},
|
||||||
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,6 +175,11 @@ describe("NotificationsPage", () => {
|
|||||||
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
|
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
|
||||||
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
|
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
|
||||||
expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId);
|
expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId);
|
||||||
|
|
||||||
|
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
|
||||||
|
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(finalExpectedSettings);
|
||||||
|
expect(editWeeklySummaryCall.memberships).toEqual(mockMemberships);
|
||||||
|
expect(editWeeklySummaryCall.environmentId).toBe(mockParams.environmentId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws error if session is not found", async () => {
|
test("throws error if session is not found", async () => {
|
||||||
@@ -187,15 +207,21 @@ describe("NotificationsPage", () => {
|
|||||||
render(PageComponent);
|
render(PageComponent);
|
||||||
|
|
||||||
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
|
||||||
|
|
||||||
const expectedEmptySettings = {
|
const expectedEmptySettings = {
|
||||||
alert: {},
|
alert: {},
|
||||||
|
weeklySummary: {},
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
|
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
|
||||||
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
|
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
|
||||||
expect(editAlertsCall.memberships).toEqual([]);
|
expect(editAlertsCall.memberships).toEqual([]);
|
||||||
|
|
||||||
|
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
|
||||||
|
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(expectedEmptySettings);
|
||||||
|
expect(editWeeklySummaryCall.memberships).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles legacy notification settings correctly", async () => {
|
test("handles legacy notification settings correctly", async () => {
|
||||||
@@ -203,6 +229,7 @@ describe("NotificationsPage", () => {
|
|||||||
id: "user-legacy",
|
id: "user-legacy",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
"survey-1": { responseFinished: true }, // Legacy alert for survey-1
|
"survey-1": { responseFinished: true }, // Legacy alert for survey-1
|
||||||
|
weeklySummary: { "project-1": true },
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
} as any, // To allow legacy structure
|
} as any, // To allow legacy structure
|
||||||
};
|
};
|
||||||
@@ -219,6 +246,9 @@ describe("NotificationsPage", () => {
|
|||||||
"survey-1": true, // Should be true due to legacy setting
|
"survey-1": true, // Should be true due to legacy setting
|
||||||
"survey-2": false, // Default for other surveys in membership
|
"survey-2": false, // Default for other surveys in membership
|
||||||
},
|
},
|
||||||
|
weeklySummary: {
|
||||||
|
"project-1": true, // From user's weeklySummary
|
||||||
|
},
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getServerSession } from "next-auth";
|
|||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||||
import { EditAlerts } from "./components/EditAlerts";
|
import { EditAlerts } from "./components/EditAlerts";
|
||||||
|
import { EditWeeklySummary } from "./components/EditWeeklySummary";
|
||||||
import { IntegrationsTip } from "./components/IntegrationsTip";
|
import { IntegrationsTip } from "./components/IntegrationsTip";
|
||||||
import type { Membership } from "./types";
|
import type { Membership } from "./types";
|
||||||
|
|
||||||
@@ -18,10 +19,14 @@ const setCompleteNotificationSettings = (
|
|||||||
): TUserNotificationSettings => {
|
): TUserNotificationSettings => {
|
||||||
const newNotificationSettings = {
|
const newNotificationSettings = {
|
||||||
alert: {},
|
alert: {},
|
||||||
|
weeklySummary: {},
|
||||||
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
||||||
};
|
};
|
||||||
for (const membership of memberships) {
|
for (const membership of memberships) {
|
||||||
for (const project of membership.organization.projects) {
|
for (const project of membership.organization.projects) {
|
||||||
|
// set default values for weekly summary
|
||||||
|
newNotificationSettings.weeklySummary[project.id] =
|
||||||
|
(notificationSettings.weeklySummary && notificationSettings.weeklySummary[project.id]) || false;
|
||||||
// set default values for alerts
|
// set default values for alerts
|
||||||
for (const environment of project.environments) {
|
for (const environment of project.environments) {
|
||||||
for (const survey of environment.surveys) {
|
for (const survey of environment.surveys) {
|
||||||
@@ -178,6 +183,11 @@ const Page = async (props) => {
|
|||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
<IntegrationsTip environmentId={params.environmentId} />
|
<IntegrationsTip environmentId={params.environmentId} />
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.notifications.weekly_summary_projects")}
|
||||||
|
description={t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")}>
|
||||||
|
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
|
||||||
|
</SettingsCard>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,20 +5,29 @@ import {
|
|||||||
verifyUserPassword,
|
verifyUserPassword,
|
||||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||||
|
import { deleteFile } from "@/lib/storage/service";
|
||||||
|
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||||
import { getUser, updateUser } from "@/lib/user/service";
|
import { getUser, updateUser } from "@/lib/user/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||||
|
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
|
||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||||
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
import { z } from "zod";
|
||||||
|
import { ZId } from "@formbricks/types/common";
|
||||||
import {
|
import {
|
||||||
TUserPersonalInfoUpdateInput,
|
AuthenticationError,
|
||||||
TUserUpdateInput,
|
AuthorizationError,
|
||||||
ZUserPersonalInfoUpdateInput,
|
OperationNotAllowedError,
|
||||||
} from "@formbricks/types/user";
|
TooManyRequestsError,
|
||||||
|
} from "@formbricks/types/errors";
|
||||||
|
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
|
||||||
|
|
||||||
|
const limiter = rateLimit({
|
||||||
|
interval: 60 * 60, // 1 hour
|
||||||
|
allowedPerInterval: 3, // max 3 calls for email verification per hour
|
||||||
|
});
|
||||||
|
|
||||||
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
||||||
return {
|
return {
|
||||||
@@ -32,15 +41,18 @@ async function handleEmailUpdate({
|
|||||||
parsedInput,
|
parsedInput,
|
||||||
payload,
|
payload,
|
||||||
}: {
|
}: {
|
||||||
ctx: AuthenticatedActionClientCtx;
|
ctx: any;
|
||||||
parsedInput: TUserPersonalInfoUpdateInput;
|
parsedInput: any;
|
||||||
payload: TUserUpdateInput;
|
payload: TUserUpdateInput;
|
||||||
}) {
|
}) {
|
||||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||||
if (!inputEmail || ctx.user.email === inputEmail) return payload;
|
if (!inputEmail || ctx.user.email === inputEmail) return payload;
|
||||||
|
|
||||||
await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id);
|
try {
|
||||||
|
await limiter(ctx.user.id);
|
||||||
|
} catch {
|
||||||
|
throw new TooManyRequestsError("Too many requests");
|
||||||
|
}
|
||||||
if (ctx.user.identityProvider !== "email") {
|
if (ctx.user.identityProvider !== "email") {
|
||||||
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
||||||
}
|
}
|
||||||
@@ -63,32 +75,90 @@ async function handleEmailUpdate({
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
export const updateUserAction = authenticatedActionClient
|
||||||
|
.schema(
|
||||||
|
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
|
||||||
|
password: ZUserPassword.optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.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 });
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const ZUpdateAvatarAction = z.object({
|
||||||
|
avatarUrl: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
|
||||||
withAuditLogging(
|
withAuditLogging(
|
||||||
"updated",
|
"updated",
|
||||||
"user",
|
"user",
|
||||||
async ({
|
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||||
ctx,
|
|
||||||
parsedInput,
|
|
||||||
}: {
|
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
parsedInput: TUserPersonalInfoUpdateInput;
|
|
||||||
}) => {
|
|
||||||
const oldObject = await getUser(ctx.user.id);
|
const oldObject = await getUser(ctx.user.id);
|
||||||
let payload = buildUserUpdatePayload(parsedInput);
|
const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
|
||||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
|
||||||
|
|
||||||
// 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.userId = ctx.user.id;
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
ctx.auditLoggingCtx.newObject = newObject;
|
ctx.auditLoggingCtx.newObject = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return true;
|
const ZRemoveAvatarAction = z.object({
|
||||||
|
environmentId: ZId,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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;
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -99,7 +169,7 @@ export const resetPasswordAction = authenticatedActionClient.action(
|
|||||||
"user",
|
"user",
|
||||||
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
|
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
|
||||||
if (ctx.user.identityProvider !== "email") {
|
if (ctx.user.identityProvider !== "email") {
|
||||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendForgotPasswordEmail(ctx.user);
|
await sendForgotPasswordEmail(ctx.user);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const mockUser = {
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
|
weeklySummary: {},
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const mockUser = {
|
|||||||
id: "user1",
|
id: "user1",
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import * as profileActions from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
|
||||||
|
import * as fileUploadHooks from "@/app/lib/fileUpload";
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { Session } from "next-auth";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { EditProfileAvatarForm } from "./EditProfileAvatarForm";
|
||||||
|
|
||||||
|
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||||
|
ProfileAvatar: ({ imageUrl }) => <div data-testid="profile-avatar">{imageUrl || "No Avatar"}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
refresh: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
|
||||||
|
updateAvatarAction: vi.fn(),
|
||||||
|
removeAvatarAction: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/lib/fileUpload", () => ({
|
||||||
|
handleFileUpload: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSession: Session = {
|
||||||
|
user: { id: "user-id" },
|
||||||
|
expires: "session-expires-at",
|
||||||
|
};
|
||||||
|
const environmentId = "test-env-id";
|
||||||
|
|
||||||
|
describe("EditProfileAvatarForm", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(profileActions.updateAvatarAction).mockResolvedValue({});
|
||||||
|
vi.mocked(profileActions.removeAvatarAction).mockResolvedValue({});
|
||||||
|
vi.mocked(fileUploadHooks.handleFileUpload).mockResolvedValue({
|
||||||
|
url: "new-avatar.jpg",
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders correctly without an existing image", () => {
|
||||||
|
render(<EditProfileAvatarForm session={mockSession} environmentId={environmentId} imageUrl={null} />);
|
||||||
|
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("No Avatar");
|
||||||
|
expect(screen.getByText("environments.settings.profile.upload_image")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("environments.settings.profile.remove_image")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders correctly with an existing image", () => {
|
||||||
|
render(
|
||||||
|
<EditProfileAvatarForm
|
||||||
|
session={mockSession}
|
||||||
|
environmentId={environmentId}
|
||||||
|
imageUrl="existing-avatar.jpg"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("existing-avatar.jpg");
|
||||||
|
expect(screen.getByText("environments.settings.profile.change_image")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("environments.settings.profile.remove_image")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles image removal successfully", async () => {
|
||||||
|
render(
|
||||||
|
<EditProfileAvatarForm
|
||||||
|
session={mockSession}
|
||||||
|
environmentId={environmentId}
|
||||||
|
imageUrl="existing-avatar.jpg"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const removeButton = screen.getByText("environments.settings.profile.remove_image");
|
||||||
|
await userEvent.click(removeButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(profileActions.removeAvatarAction).toHaveBeenCalledWith({ environmentId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error if removeAvatarAction fails", async () => {
|
||||||
|
vi.mocked(profileActions.removeAvatarAction).mockRejectedValue(new Error("API error"));
|
||||||
|
render(
|
||||||
|
<EditProfileAvatarForm
|
||||||
|
session={mockSession}
|
||||||
|
environmentId={environmentId}
|
||||||
|
imageUrl="existing-avatar.jpg"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const removeButton = screen.getByText("environments.settings.profile.remove_image");
|
||||||
|
await userEvent.click(removeButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
|
||||||
|
"environments.settings.profile.avatar_update_failed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
removeAvatarAction,
|
||||||
|
updateAvatarAction,
|
||||||
|
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
|
||||||
|
import { handleFileUpload } from "@/app/lib/fileUpload";
|
||||||
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { FormError, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useTranslate } from "@tolgee/react";
|
||||||
|
import { Session } from "next-auth";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
interface EditProfileAvatarFormProps {
|
||||||
|
session: Session;
|
||||||
|
environmentId: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: EditProfileAvatarFormProps) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslate();
|
||||||
|
const fileSchema =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? z
|
||||||
|
.instanceof(FileList)
|
||||||
|
.refine((files) => files.length === 1, t("environments.settings.profile.you_must_select_a_file"))
|
||||||
|
.refine((files) => {
|
||||||
|
const file = files[0];
|
||||||
|
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||||
|
return allowedTypes.includes(file.type);
|
||||||
|
}, t("environments.settings.profile.invalid_file_type"))
|
||||||
|
.refine((files) => {
|
||||||
|
const file = files[0];
|
||||||
|
const maxSize = 10 * 1024 * 1024;
|
||||||
|
return file.size <= maxSize;
|
||||||
|
}, t("environments.settings.profile.file_size_must_be_less_than_10mb"))
|
||||||
|
: z.any();
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
file: fileSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
mode: "onChange",
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUpload = async (file: File, environmentId: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (imageUrl) {
|
||||||
|
// If avatar image already exists, then remove it before update action
|
||||||
|
await removeAvatarAction({ environmentId });
|
||||||
|
}
|
||||||
|
const { url, error } = await handleFileUpload(file, environmentId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAvatarAction({ avatarUrl: url });
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t("environments.settings.profile.avatar_update_failed"));
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeAvatarAction({ environmentId });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t("environments.settings.profile.avatar_update_failed"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormValues) => {
|
||||||
|
const file = data.file[0];
|
||||||
|
if (file) {
|
||||||
|
await handleUpload(file, environmentId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="relative h-10 w-10 overflow-hidden rounded-full">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
|
||||||
|
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProfileAvatar userId={session.user.id} imageUrl={imageUrl} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4">
|
||||||
|
<FormField
|
||||||
|
name="file"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="mr-2"
|
||||||
|
variant={!!fieldState.error?.message ? "destructive" : "secondary"}
|
||||||
|
onClick={() => {
|
||||||
|
inputRef.current?.click();
|
||||||
|
}}>
|
||||||
|
{imageUrl
|
||||||
|
? t("environments.settings.profile.change_image")
|
||||||
|
: t("environments.settings.profile.upload_image")}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="hiddenFileInput"
|
||||||
|
ref={(e) => {
|
||||||
|
field.ref(e);
|
||||||
|
inputRef.current = e;
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
accept="image/jpeg, image/png, image/webp"
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.files);
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{imageUrl && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="mr-2"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemove}>
|
||||||
|
{t("environments.settings.profile.remove_image")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormError />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ const mockUser = {
|
|||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
|
weeklySummary: {},
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export const EditProfileDetailsForm = ({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = getFormattedErrorMessage(result);
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
toast.error(errorMessage);
|
toast.error(t(errorMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsResettingPassword(false);
|
setIsResettingPassword(false);
|
||||||
|
|||||||
@@ -49,12 +49,15 @@ describe("Loading", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadingCards = screen.getAllByTestId("loading-card");
|
const loadingCards = screen.getAllByTestId("loading-card");
|
||||||
expect(loadingCards).toHaveLength(2);
|
expect(loadingCards).toHaveLength(3);
|
||||||
|
|
||||||
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information");
|
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information");
|
||||||
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info");
|
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info");
|
||||||
|
|
||||||
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.delete_account");
|
expect(loadingCards[1]).toHaveTextContent("common.avatar");
|
||||||
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
|
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.organization_identification");
|
||||||
|
|
||||||
|
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.delete_account");
|
||||||
|
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ const Loading = () => {
|
|||||||
{ classes: "h-6 w-64" },
|
{ classes: "h-6 w-64" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t("common.avatar"),
|
||||||
|
description: t("environments.settings.profile.organization_identification"),
|
||||||
|
skeletonLines: [{ classes: "h-10 w-10" }, { classes: "h-8 w-24" }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("environments.settings.profile.delete_account"),
|
title: t("environments.settings.profile.delete_account"),
|
||||||
description: t("environments.settings.profile.confirm_delete_account"),
|
description: t("environments.settings.profile.confirm_delete_account"),
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ vi.mock(
|
|||||||
vi.mock("./components/DeleteAccount", () => ({
|
vi.mock("./components/DeleteAccount", () => ({
|
||||||
DeleteAccount: ({ user }) => <div data-testid="delete-account">DeleteAccount: {user.id}</div>,
|
DeleteAccount: ({ user }) => <div data-testid="delete-account">DeleteAccount: {user.id}</div>,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("./components/EditProfileAvatarForm", () => ({
|
||||||
|
EditProfileAvatarForm: ({ _, environmentId }) => (
|
||||||
|
<div data-testid="edit-profile-avatar-form">EditProfileAvatarForm: {environmentId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
vi.mock("./components/EditProfileDetailsForm", () => ({
|
vi.mock("./components/EditProfileDetailsForm", () => ({
|
||||||
EditProfileDetailsForm: ({ user }) => (
|
EditProfileDetailsForm: ({ user }) => (
|
||||||
<div data-testid="edit-profile-details-form">EditProfileDetailsForm: {user.id}</div>
|
<div data-testid="edit-profile-details-form">EditProfileDetailsForm: {user.id}</div>
|
||||||
@@ -68,9 +73,10 @@ const mockUser = {
|
|||||||
id: "user-123",
|
id: "user-123",
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
|
imageUrl: "http://example.com/avatar.png",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
@@ -111,12 +117,12 @@ describe("ProfilePage", () => {
|
|||||||
"AccountSettingsNavbar: env-123 profile"
|
"AccountSettingsNavbar: env-123 profile"
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("edit-profile-details-form")).toBeInTheDocument();
|
expect(screen.getByTestId("edit-profile-details-form")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("edit-profile-avatar-form")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
|
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
|
||||||
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
|
||||||
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
|
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
|
||||||
// Check for IdBadge content
|
// Use a regex to match the text content, allowing for variable whitespace
|
||||||
expect(screen.getByText("common.profile_id")).toBeInTheDocument();
|
expect(screen.getByText(new RegExp(`common\\.profile\\s*:\\s*${mockUser.id}`))).toBeInTheDocument(); // SettingsId
|
||||||
expect(screen.getByText(mockUser.id)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user